diff --git a/CHANGELOG.md b/CHANGELOG.md index 83d8e3422..2ab730c2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- NestJS routes are now named by the path a request takes: the global prefix from `app.setGlobalPrefix('api')` is applied, along with its `exclude` list, and so is URI versioning from `app.enableVersioning(...)`, with `@Version`, `VERSION_NEUTRAL` and a controller's own `version` taken into account. A route that used to appear as `GET /user` is now `GET /api/v1/user`, so a front end's `this.http.get('/api/v1/user')` or `fetch` call connects to the controller method that serves it. Re-index NestJS projects after upgrading. - Laravel routes are now named by the path a request takes: a leading `/` is added where the routes file leaves it out, `Route::prefix()` and `Route::group(['prefix' => …])` groups are applied, and routes in `routes/api.php` carry the `/api` prefix Laravel serves them under, read from your `RouteServiceProvider`, `bootstrap/app.php` or any file that mounts a routes file. A front-end `fetch('/api/…')` now connects to the Laravel route that serves it. Re-index Laravel projects after upgrading. - Controllers that Spring or Laravel tests exercise by URL now count as tested. That covers MockMvc's `perform(post("/owners/new"))`, WebTestClient, TestRestTemplate, RestAssured, Laravel's `$this->postJson('api/me')`, Pest's `get('/about')`, and a project's own request helpers built on them. `codegraph_explore` now names the test suite that reaches such an endpoint, where before every one of them looked untested. Re-index Spring and Laravel projects after upgrading. - In an Angular app, a method a template calls, like `(click)="toggleFavorite()"` or `(ngSubmit)="submitForm()"`, is now linked from its component. These methods used to show no callers at all, so impact and callers questions stopped at them, and dead-code checks could list them as unused. Re-index Angular projects after upgrading. diff --git a/README.md b/README.md index fc5d341bc..e0aae0886 100644 --- a/README.md +++ b/README.md @@ -322,7 +322,7 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by | **Flask** | `@app.route('/path', methods=[...])`, blueprint routes | | **FastAPI** | `@app.get(...)`, `@router.post(...)`, all standard methods | | **Express** | `app.get(...)`, `router.post(...)` with middleware chains | -| **NestJS** | `@Controller` + `@Get/@Post/...`, GraphQL `@Resolver` + `@Query/@Mutation`, `@MessagePattern`/`@EventPattern`, `@SubscribeMessage` | +| **NestJS** | `@Controller` + `@Get/@Post/...` (with `RouterModule` prefixes, `setGlobalPrefix` and URI versioning), GraphQL `@Resolver` + `@Query/@Mutation`, `@MessagePattern`/`@EventPattern`, `@SubscribeMessage` | | **Laravel** | `Route::get()`, `Route::resource()`, `Controller@action`, tuple syntax | | **Drupal** | `*.routing.yml` routes (`_controller`, `_form`, entity handlers); `hook_*` implementations in `.module`/`.theme`/`.install`/`.inc` | | **Rails** | `get '/x', to: 'users#index'`, hash-rocket `=>` syntax | diff --git a/__tests__/nestjs-global-prefix.test.ts b/__tests__/nestjs-global-prefix.test.ts new file mode 100644 index 000000000..fcb554a53 --- /dev/null +++ b/__tests__/nestjs-global-prefix.test.ts @@ -0,0 +1,217 @@ +/** + * A NestJS route is named by the path a request takes to it — the app's + * global prefix and URI version included. + * + * `app.setGlobalPrefix('api')` and `app.enableVersioning({ type: + * VersioningType.URI, defaultVersion: '1' })` in `main.ts` put every + * controller under `/api/v1`, but the routes were named `GET /user`. A front + * end's `this.http.get('/api/v1/user')` then connected to nothing: Ghostfolio's + * Angular client had no link to its own NestJS API. + */ +import { describe, it, expect, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +const projects: string[] = []; +afterAll(() => { + for (const p of projects.splice(0)) fs.rmSync(p, { recursive: true, force: true }); +}); + +async function project(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-nest-prefix-')); + projects.push(root); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + return CodeGraph.init(root, { index: true }); +} + +const MAIN = `import { NestFactory } from '@nestjs/core'; +import { VersioningType } from '@nestjs/common'; +import { AppModule } from './app/app.module'; +async function bootstrap() { + const app = await NestFactory.create(AppModule); + app.enableVersioning({ defaultVersion: '1', type: VersioningType.URI }); + app.setGlobalPrefix('api', { exclude: ['sitemap.xml', 'health{/*wildcard}'] }); + await app.listen(3333); +} +bootstrap(); +`; + +describe('NestJS: the global prefix and URI versioning', () => { + it('names each route under the prefix and its version — or neither, where the app says so', async () => { + const cg = await project({ + 'package.json': JSON.stringify({ dependencies: { '@nestjs/core': '^11.0.0', '@nestjs/common': '^11.0.0' } }), + 'apps/api/src/main.ts': MAIN, + // A seed script creates the same app without serving it — and is read first. + 'apps/api/src/database/run-seed.ts': `import { NestFactory } from '@nestjs/core'; +const runSeed = async () => { + const app = await NestFactory.create(SeedModule); + await app.close(); +}; +void runSeed(); +`, + 'apps/api/src/app/user.controller.ts': `import { Controller, Get, Post, Version, VERSION_NEUTRAL } from '@nestjs/common'; +@Controller('user') +export class UserController { + @Get() + public getUser() {} + + @Post() + @UseGuards(AuthGuard('jwt')) + @Version('2') + public createUser() {} + + @Get('oidc/callback') + @Version(VERSION_NEUTRAL) + public callback() {} +} +`, + 'apps/api/src/app/sitemap.controller.ts': `import { Controller, Get, Version, VERSION_NEUTRAL } from '@nestjs/common'; +@Controller() +@Version(VERSION_NEUTRAL) +export class SitemapController { + @Get('sitemap.xml') + public sitemap() {} +} +`, + 'apps/api/src/app/health.controller.ts': `import { Controller, Get } from '@nestjs/common'; +@Controller({ path: 'health', version: '3' }) +export class HealthController { + @Get('db') + public db() {} +} +`, + // A second app that sets nothing: its routes stay as written. + 'apps/jobs/src/main.ts': `import { NestFactory } from '@nestjs/core'; +NestFactory.create(JobsModule).then((app) => app.listen(3335)); +`, + 'apps/jobs/src/jobs.controller.ts': `import { Controller, Get } from '@nestjs/common'; +@Controller('jobs') +export class JobsController { + @Get() + public list() {} +} +`, + }); + try { + expect( + cg + .getNodesByKind('route') + .map((r) => r.name) + .sort() + ).toEqual([ + // VERSION_NEUTRAL: prefixed, not versioned. + 'GET /api/user/oidc/callback', + 'GET /api/v1/user', + 'GET /jobs', + 'GET /sitemap.xml', + // Excluded from the prefix, still versioned by its controller. + 'GET /v3/health/db', + 'POST /api/v2/user', + ]); + } finally { + cg.close(); + } + }); + + it('reads a version past decorators with object arguments, and each app its own bootstrap', async () => { + const cg = await project({ + 'package.json': JSON.stringify({ dependencies: { '@nestjs/core': '^11.0.0', '@nestjs/common': '^11.0.0' } }), + 'apps/api/src/main.ts': MAIN, + 'apps/api/src/app/stats.controller.ts': `import { Controller, Get, Version } from '@nestjs/common'; +@Controller('stats') +export class StatsController { + @Version('2') + @ApiResponse({ status: 200, description: 'The stats' }) + @Get('daily') + public daily() {} + + @Get('weekly') + public weekly() {} +} +`, + 'apps/api/src/app/reports.controller.ts': `import { Controller, Get, Version } from '@nestjs/common'; +@Version('4') +@ApiTags({ name: 'reports' }) +@Controller('reports') +export class ReportsController { + @Get() + public list() {} +} +`, + // A second app whose prefix is read from config: no prefix is claimed for + // it, and the other app's `/api` is not borrowed. + 'apps/admin/src/main.ts': `import { NestFactory } from '@nestjs/core'; +async function bootstrap() { + const app = await NestFactory.create(AdminModule); + app.setGlobalPrefix(config.get('app.prefix')); + app.enableVersioning({ defaultVersion: '1' }); + await app.listen(3334); +} +bootstrap(); +`, + 'apps/admin/src/users.controller.ts': `import { Controller, Get } from '@nestjs/common'; +@Controller('users') +export class UsersController { + @Get() + public list() {} +} +`, + }); + try { + expect( + cg + .getNodesByKind('route') + .map((r) => r.name) + .sort() + ).toEqual(['GET /api/v1/stats/weekly', 'GET /api/v2/stats/daily', 'GET /api/v4/reports', 'GET /v1/users']); + } finally { + cg.close(); + } + }); + + it("connects a front end's request to the route it reaches, and leaves an Express route in the same repo alone", async () => { + const cg = await project({ + 'package.json': JSON.stringify({ + dependencies: { '@nestjs/core': '^11.0.0', '@nestjs/common': '^11.0.0', '@angular/core': '^19.0.0', express: '^4.0.0' }, + }), + 'apps/api/src/main.ts': MAIN, + 'apps/api/src/app/user.controller.ts': `import { Controller, Get } from '@nestjs/common'; +@Controller('user') +export class UserController { + @Get() + public getUser() {} +} +`, + 'apps/client/src/app/user.service.ts': `import { HttpClient } from '@angular/common/http'; +export class UserService { + constructor(private http: HttpClient) {} + public fetchUser() { + return this.http.get('/api/v1/user'); + } +} +`, + 'tools/mock-server/src/server.ts': `import express from 'express'; +const app = express(); +app.get('/status', (req, res) => res.json({ ok: true })); +`, + }); + try { + const names = cg.getNodesByKind('route').map((r) => r.name).sort(); + expect(names).toContain('GET /api/v1/user'); + expect(names).toContain('GET /status'); + const fetchUser = cg.getNodesByName('fetchUser')[0]!; + const reached = cg + .getOutgoingEdgesFrom([fetchUser.id], ['calls']) + .filter((e) => (e.metadata as Record | undefined)?.channel === 'http') + .map((e) => cg.getNode(e.target)?.name); + expect(reached).toEqual(['GET /api/v1/user']); + } finally { + cg.close(); + } + }); +}); diff --git a/src/resolution/frameworks/nestjs.ts b/src/resolution/frameworks/nestjs.ts index 6ea13f5da..dbadbbc9e 100644 --- a/src/resolution/frameworks/nestjs.ts +++ b/src/resolution/frameworks/nestjs.ts @@ -208,14 +208,22 @@ export const nestjsResolver: FrameworkResolver = { postExtract(context: ResolutionContext): Node[] { const moduleToPrefix = new Map(); const controllerToModule = new Map(); + const bootstraps: NestBootstrap[] = []; for (const filePath of context.getAllFiles()) { - if (!/\.module\.(m?[jt]s|cjs)$/.test(filePath)) continue; + if (!/\.(m?[jt]s|cjs)$/.test(filePath)) continue; + const isModule = /\.module\.(m?[jt]s|cjs)$/.test(filePath); + if (!isModule && !(context.fileContains?.(filePath, 'NestFactory') ?? true)) continue; const content = context.readFile(filePath); if (!content) continue; const safe = stripCommentsForRegex(content, detectLanguage(filePath)); - collectRouterModuleRegistrations(safe, moduleToPrefix); - collectModuleControllers(safe, controllerToModule); + if (isModule) { + collectRouterModuleRegistrations(safe, moduleToPrefix); + collectModuleControllers(safe, controllerToModule); + } + // Every app counts, even one that adds nothing: its routes must not + // borrow another app's prefix. + if (/\bNestFactory\s*\.\s*create/.test(safe)) bootstraps.push({ root: appRootOf(filePath), config: readBootstrap(safe) }); } const controllerToPrefix = new Map(); @@ -228,35 +236,243 @@ export const nestjsResolver: FrameworkResolver = { } } - if (controllerToPrefix.size === 0) return []; + const adds = bootstraps.some((b) => b.config.prefix !== '' || b.config.uri); + if (controllerToPrefix.size === 0 && !adds) return []; - const updates: Node[] = []; + // Route → what the app adds to it. A RouterModule prefix binds through the + // controller class's line range; the bootstrap's prefix and versioning + // only to a route its source shows is in a `@Controller` — an Express + // route has the same qualified-name shape. + const plan = new Map(); for (const [controllerName, prefix] of controllerToPrefix) { - const classes = context - .getNodesByName(controllerName) - .filter((n) => n.kind === 'class'); - for (const cls of classes) { - const routes = context - .getNodesInFile(cls.filePath) - .filter((n) => n.kind === 'route'); + for (const cls of context.getNodesByName(controllerName).filter((n) => n.kind === 'class')) { + for (const route of context.getNodesInFile(cls.filePath)) { + if (route.kind !== 'route' || route.startLine < cls.startLine || route.startLine > cls.endLine) continue; + plan.set(route.id, { route, modulePrefix: prefix, config: null, version: undefined }); + } + } + } + if (adds) { + for (const filePath of context.getAllFiles()) { + if (!/\.(m?[jt]s|cjs)$/.test(filePath)) continue; + if (!(context.fileContains?.(filePath, '@Controller') ?? true)) continue; + const config = bootstrapFor(bootstraps, filePath); + if (!config || (config.prefix === '' && !config.uri)) continue; + const routes = context.getNodesInFile(filePath).filter((n) => n.kind === 'route' && HTTP_ROUTE_QN.test(n.qualifiedName)); + if (routes.length === 0) continue; + const content = context.readFile(filePath); + if (!content) continue; + const safe = stripCommentsForRegex(content, detectLanguage(filePath)); + const scopes = buildClassScopes(safe); + const hits = findDecorators(safe, HTTP_METHODS); for (const route of routes) { - // Multiple controllers can live in one file (covered by the - // existing "attributes methods to the right controller" test); - // each route must be associated with the controller whose line - // range contains it. - if (route.startLine < cls.startLine || route.startLine > cls.endLine) { - continue; - } - const updated = applyModulePrefix(route, prefix); - if (updated && updated.name !== route.name) updates.push(updated); + const hit = hits.find((h) => lineAt(safe, h.index) === route.startLine); + const scope = hit ? scopeFor(scopes, hit.index) : null; + if (!hit || !scope || scope.kind !== 'controller') continue; + const planned = plan.get(route.id); + plan.set(route.id, { route, modulePrefix: planned?.modulePrefix ?? '', config, version: routeVersion(safe, hit, scope) }); } } } + const updates: Node[] = []; + for (const { route, modulePrefix, config, version } of plan.values()) { + const updated = applyAppConfig(route, modulePrefix, config, version); + if (updated && updated.name !== route.name) updates.push(updated); + } return updates; }, }; +// --------------------------------------------------------------------------- +// The app's bootstrap: global prefix and URI versioning +// --------------------------------------------------------------------------- + +/** A route's in-file shape in its qualified name: `file::GET:/users`. */ +const HTTP_ROUTE_QN = /::(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|ALL):/; + +/** The version marker for `VERSION_NEUTRAL`: served without a version segment. */ +const NEUTRAL = '\u0000neutral'; + +interface NestAppConfig { + /** `setGlobalPrefix('api')` — '' when none, or not a literal. */ + prefix: string; + /** Paths the prefix skips: `exclude: ['health', { path: 'metrics', method }]`, without a leading `/`. */ + excludes: string[]; + /** URI versioning is on (`enableVersioning({ type: VersioningType.URI })`, the default type). */ + uri: boolean; + /** `defaultVersion: '1'` — the version a route without its own is served at; null for none. */ + defaultVersion: string | null; + /** The segment's prefix: `v` unless `prefix:` says otherwise (`false` is none). */ + versionPrefix: string; +} + +interface NestBootstrap { + root: string; + config: NestAppConfig; +} + +/** The app a file belongs to: the directory above its `src/` (`apps/api/`), else the repository. */ +function appRootOf(filePath: string): string { + const m = /^((?:[^/]+\/)*?)src\//.exec(filePath); + return m ? m[1]! : ''; +} + +/** + * The config of the app that holds `filePath`: the bootstrap with the longest + * root above it, else — for a file outside every app, like an Nx `libs/` + * controller — the app, when there is only one. + */ +function bootstrapFor(bootstraps: readonly NestBootstrap[], filePath: string): NestAppConfig | null { + let root: string | null = null; + for (const b of bootstraps) { + if (filePath.startsWith(b.root) && (root === null || b.root.length > root.length)) root = b.root; + } + if (root === null) { + if (new Set(bootstraps.map((b) => b.root)).size !== 1) return null; + root = bootstraps[0]!.root; + } + // A seed or migration script creates the same app without serving it: the + // bootstrap that configures the app is the one that serves it. + const here = bootstraps.filter((b) => b.root === root); + return (here.find((b) => b.config.prefix !== '' || b.config.uri) ?? here[0])?.config ?? null; +} + +/** One version value: `'2'`, `['1', '2']` (the first), `VERSION_NEUTRAL`. */ +function versionValue(text: string): string | null { + const t = text.trim(); + if (/^VERSION_NEUTRAL\b/.test(t)) return NEUTRAL; + const lit = /^\[?\s*(['"`])([^'"`]*)\1/.exec(t); + return lit ? lit[2]! : null; +} + +/** `setGlobalPrefix` and `enableVersioning` from the file that calls `NestFactory.create`. */ +function readBootstrap(safe: string): NestAppConfig { + const config: NestAppConfig = { prefix: '', excludes: [], uri: false, defaultVersion: null, versionPrefix: 'v' }; + const prefixCall = /\.setGlobalPrefix\s*\(/.exec(safe); + if (prefixCall) { + const open = prefixCall.index + prefixCall[0].length - 1; + const close = matchingClose(safe, open); + const args = close > open ? safe.slice(open + 1, close) : ''; + const lit = /^\s*(['"`])([^'"`$]*)\1/.exec(args); + if (lit) { + config.prefix = lit[2]!.replace(/^\/+|\/+$/g, ''); + const ex = /\bexclude\s*:\s*\[/.exec(args); + if (ex) { + const exOpen = ex.index + ex[0].length - 1; + const exClose = matchingClose(args, exOpen); + const list = exClose > exOpen ? args.slice(exOpen + 1, exClose) : ''; + for (const item of splitTopLevel(list)) { + const text = item.trim(); + const path = /^(['"])([^'"]*)\1$/.exec(text)?.[2] ?? /\bpath\s*:\s*(['"])([^'"]*)\1/.exec(text)?.[2]; + if (path !== undefined) config.excludes.push(path.replace(/^\/+/, '')); + } + } + } + } + const versioning = /\.enableVersioning\s*\(/.exec(safe); + if (versioning) { + const open = versioning.index + versioning[0].length - 1; + const close = matchingClose(safe, open); + const args = close > open ? safe.slice(open + 1, close) : ''; + const type = /\btype\s*:\s*VersioningType\s*\.\s*(\w+)/.exec(args)?.[1]; + config.uri = type === undefined || type === 'URI'; + const def = /\bdefaultVersion\s*:\s*/.exec(args); + if (def) config.defaultVersion = versionValue(args.slice(def.index + def[0].length)); + const pre = /\bprefix\s*:\s*(false|(['"`])([^'"`]*)\2)/.exec(args); + if (pre) config.versionPrefix = pre[1] === 'false' ? '' : pre[3]!; + } + return config; +} + +/** A route excluded from the global prefix: `health`, `health/(.*)`, `health{/*wildcard}`, `health/*`. */ +function isExcluded(path: string, excludes: readonly string[]): boolean { + const route = path.replace(/^\/+/, ''); + for (const pattern of excludes) { + const wild = /(?:\{\/?\*\w*\}|\/?\(\.\*\)|\/?\*\w*)$/.exec(pattern); + const base = wild ? pattern.slice(0, wild.index).replace(/\/+$/, '') : pattern.replace(/\/+$/, ''); + if (route === base || (wild && route.startsWith(`${base}/`))) return true; + } + return false; +} + +/** + * The version a route is served at: `@Version(…)` among its own decorators, + * else its controller's (`@Version(…)` on the class, `@Controller({ version })`). + * Undefined when neither says. + */ +function routeVersion(safe: string, hit: DecoratorHit, scope: ClassScope): string | null | undefined { + // The method's decorators: back through the ones stacked above this one… + const from = decoratorChainStart(safe, hit.index, scope.start); + // …and on to the handler itself — past the decorators between (`@UseGuards(…)`, `@Version('2')`). + const handler = methodNameAfter(safe, hit.end); + const at = handler ? new RegExp(String.raw`(? floor && /\s/.test(safe[i - 1]!)) i--; + if (safe[i - 1] === ')') { + let depth = 0; + let j = i - 1; + for (; j >= floor; j--) { + if (safe[j] === ')') depth++; + else if (safe[j] === '(' && --depth === 0) break; + } + if (j < floor) return start; + i = j; + } + let name = i; + while (name > floor && /[\w$.]/.test(safe[name - 1]!)) name--; + if (name === i || safe[name - 1] !== '@') return start; + start = name - 1; + } +} + +/** + * A route's name with everything the app adds to it, in NestJS's order: + * `/{globalPrefix}/{v}{version}/{RouterModule prefix}/{controller}/{method}`. + * Recomputed from the in-file `method:path` in the qualified name, so the pass + * is idempotent. + */ +function applyAppConfig(route: Node, modulePrefix: string, config: NestAppConfig | null, version: string | null | undefined): Node | null { + const sep = route.qualifiedName.indexOf('::'); + if (sep < 0) return null; + const tail = route.qualifiedName.slice(sep + 2); + const colon = tail.indexOf(':'); + if (colon < 0) return null; + const method = tail.slice(0, colon); + const inner = joinHttpPath(modulePrefix, tail.slice(colon + 1)); + let path = inner; + if (config) { + const v = version === undefined ? config.defaultVersion : version; + const versionSeg = config.uri && v !== null && v !== NEUTRAL ? `${config.versionPrefix}${v}` : ''; + const prefix = config.prefix && !isExcluded(inner, config.excludes) ? config.prefix : ''; + path = joinHttpPath(joinHttpPath(prefix, versionSeg), inner); + } + return { ...route, name: `${method} ${path}`, updatedAt: Date.now() }; +} + // --------------------------------------------------------------------------- // Provider resolution conventions // --------------------------------------------------------------------------- @@ -646,29 +862,36 @@ function classNameAfter(safe: string, start: number): string | null { return m ? m[1]! : null; } -/** - * Recompute a route node's `name` by prepending `prefix` to the *original* - * in-file path. The original is recovered from `qualifiedName`, which the - * per-file extract emits as `${filePath}::${method}:${path}` and which this - * pass deliberately never mutates — that's what keeps the update idempotent. - */ -function applyModulePrefix(route: Node, prefix: string): Node | null { - const sep = '::'; - const idx = route.qualifiedName.indexOf(sep); - if (idx < 0) return null; - const tail = route.qualifiedName.slice(idx + sep.length); - const colon = tail.indexOf(':'); - if (colon < 0) return null; - const method = tail.slice(0, colon); - const original = tail.slice(colon + 1); - const newName = `${method} ${joinHttpPath(prefix, original)}`; - return { ...route, name: newName, updatedAt: Date.now() }; -} // --------------------------------------------------------------------------- // Small string utilities (object/array literal splitters) // --------------------------------------------------------------------------- +/** A list's comma-separated items at depth 0, strings and brackets stepped over. */ +function splitTopLevel(list: string): string[] { + const out: string[] = []; + let depth = 0; + let inStr: string | null = null; + let start = 0; + for (let i = 0; i < list.length; i++) { + const ch = list[i]!; + if (inStr) { + if (ch === '\\') { i++; continue; } + if (ch === inStr) inStr = null; + continue; + } + if (ch === '"' || ch === "'" || ch === '`') { inStr = ch; continue; } + if (ch === '[' || ch === '{' || ch === '(') depth++; + else if (ch === ']' || ch === '}' || ch === ')') depth--; + else if (ch === ',' && depth === 0) { + out.push(list.slice(start, i)); + start = i + 1; + } + } + out.push(list.slice(start)); + return out.filter((item) => item.trim().length > 0); +} + /** Return the index of the bracket that closes the one at `open`, or -1. */ function matchingClose(s: string, open: number): number { const opener = s[open];