UNPKG

@electric-sql/client

Version:

Postgres everywhere - your data, in sync, wherever you need it.

1,685 lines (1,477 loc) 52.9 kB
import fs from 'node:fs' import path from 'node:path' import { execFileSync } from 'node:child_process' import { fileURLToPath } from 'node:url' import ts from 'typescript' const PACKAGE_DIR = path.resolve( path.dirname(fileURLToPath(import.meta.url)), `..`, `..` ) const GIT_ROOT = resolveGitRoot() const CLIENT_FILE = path.join(PACKAGE_DIR, `src`, `client.ts`) const STATE_MACHINE_FILE = path.join( PACKAGE_DIR, `src`, `shape-stream-state.ts` ) const ANALYSIS_DIRS = [`src`, `test`, `bin`] const ANALYSIS_EXTENSIONS = new Set([`.ts`, `.tsx`, `.js`, `.mjs`]) const ANALYSIS_EXCLUDED_DIRS = new Set([ `dist`, `node_modules`, `junit`, `coverage`, `fixtures`, ]) const PROTOCOL_LITERAL_METHODS = new Set([ `get`, `set`, `has`, `append`, `delete`, ]) const PROTOCOL_LITERAL_CANONICAL_VALUES = [ `electric-cursor`, `electric-handle`, `electric-offset`, `electric-schema`, `electric-up-to-date`, `cursor`, `expired_handle`, `handle`, `live`, `offset`, `table`, `where`, `replica`, `params`, `experimental_live_sse`, `live_sse`, `log`, `subset__where`, `subset__limit`, `subset__offset`, `subset__order_by`, `subset__params`, `subset__where_expr`, `subset__order_by_expr`, `cache-buster`, ] const PROTOCOL_LITERAL_BY_NORMALIZED = new Map( PROTOCOL_LITERAL_CANONICAL_VALUES.map((value) => [ normalizeLiteral(value), value, ]) ) const SHARED_FIELD_IGNORE = new Set([ `#syncState`, `#started`, `#connected`, `#error`, `#messageChain`, `#onError`, `#mode`, `#pauseLock`, `#subscribers`, `#snapshotTracker`, `#snapshotCounter`, `#unsubscribeFromVisibilityChanges`, `#unsubscribeFromWakeDetection`, `#transformer`, `#currentFetchUrl`, `#tickPromise`, ]) const ALLOWED_IGNORED_ACTION_CLASSES = new Set([`ErrorState`, `PausedState`]) export function analyzeTypeScriptClient(options = {}) { const packageDir = options.packageDir ?? PACKAGE_DIR const clientFile = path.join(packageDir, `src`, `client.ts`) const stateMachineFile = path.join(packageDir, `src`, `shape-stream-state.ts`) const clientAnalysis = analyzeShapeStreamClient(clientFile) const stateMachineAnalysis = analyzeStateMachine(stateMachineFile) const protocolLiteralAnalysis = analyzeProtocolLiterals( listAnalysisFiles(packageDir), { requireConstantsInFiles: (filePath) => filePath.includes(`${path.sep}src${path.sep}`), } ) const findings = clientAnalysis.findings .concat(stateMachineAnalysis.findings) .concat(protocolLiteralAnalysis.findings) .sort(compareFindings) return { packageDir, clientFile, stateMachineFile, findings, reports: { recursiveMethods: clientAnalysis.recursiveMethods, sharedFieldReport: clientAnalysis.sharedFieldReport, unboundedRetryReport: clientAnalysis.unboundedRetryReport, cacheBusterReport: clientAnalysis.cacheBusterReport, tailPositionAwaitReport: clientAnalysis.tailPositionAwaitReport, errorPathPublishReport: clientAnalysis.errorPathPublishReport, ignoredActionReport: stateMachineAnalysis.ignoredActionReport, protocolLiteralReport: protocolLiteralAnalysis.report, }, } } export function analyzeShapeStreamClient(filePath = CLIENT_FILE) { const sourceFile = readSourceFile(filePath) const classDecl = sourceFile.statements.find( (statement) => ts.isClassDeclaration(statement) && statement.name?.text === `ShapeStream` ) if (!classDecl) { throw new Error(`Could not find ShapeStream class in ${filePath}`) } const classInfo = buildClassInfo(sourceFile, classDecl) const recursiveMethods = buildRecursiveMethodReport(classInfo) const sharedFieldReport = buildSharedFieldReport(classInfo) const unboundedRetryReport = buildUnboundedRetryReport( sourceFile, classDecl, recursiveMethods ) const cacheBusterReport = build409CacheBusterReport(sourceFile, classDecl) const tailPositionAwaitReport = buildTailPositionAwaitReport( sourceFile, classDecl, recursiveMethods ) const errorPathPublishReport = buildErrorPathPublishReport( sourceFile, classDecl ) const findings = sharedFieldReport .filter((report) => report.risky) .map((report) => ({ kind: `shared-instance-field`, severity: `warning`, title: `Shared mutable field spans async boundaries: ${report.field}`, message: `${report.field} is written before an await or async internal call and ` + `is also consumed by other methods. This can leak retry/cache-buster state ` + `across concurrent call chains.`, file: filePath, line: report.primaryLine, locations: uniqueLocations([ { file: filePath, line: report.primaryLine, label: `first async write`, }, ...report.writerLines.map((line) => ({ file: filePath, line, label: `writer`, })), ...report.readerLines.map((line) => ({ file: filePath, line, label: `reader/reset`, })), ]), details: { field: report.field, writerMethods: report.writerMethods, readerMethods: report.readerMethods, reasons: report.reasons, }, })) .concat( unboundedRetryReport .filter((entry) => !entry.hasBoundCheck) .map((entry) => ({ kind: `unbounded-retry-loop`, severity: `warning`, title: `Unbounded recursive retry: ${entry.method} -> ${entry.callee}`, message: `${entry.method} contains a recursive call to ${entry.callee} at line ${entry.callLine} ` + `inside a catch block with no detectable retry bound (counter, type guard, or abort check). ` + `This can cause infinite retries when the error condition persists.`, file: filePath, line: entry.callLine, locations: [ { file: filePath, line: entry.catchLine, label: `catch block` }, { file: filePath, line: entry.callLine, label: `recursive call` }, ], details: entry, })) ) .concat( cacheBusterReport .filter((entry) => !entry.unconditional) .map((entry) => ({ kind: `conditional-409-cache-buster`, severity: `warning`, title: `409 handler has conditional or missing cache buster in ${entry.method}`, message: `${entry.method} handles status 409 and retries via ${entry.retryCallee} at line ${entry.retryLine} ` + `but createCacheBuster() is ${entry.cacheBusterLine ? `conditional (line ${entry.cacheBusterLine})` : `missing`}. ` + `Every 409 retry must include an unconditional cache buster to guarantee a unique retry URL, ` + `otherwise same-handle 409s or proxy-cached responses can cause infinite retry loops.`, file: filePath, line: entry.retryLine, locations: [ { file: filePath, line: entry.statusCheckLine, label: `409 check` }, ...(entry.cacheBusterLine ? [ { file: filePath, line: entry.cacheBusterLine, label: `conditional cache buster`, }, ] : []), { file: filePath, line: entry.retryLine, label: `retry call` }, ], details: entry, })) ) .concat( tailPositionAwaitReport .filter((entry) => entry.isParked) .map((entry) => ({ kind: `parked-tail-await`, severity: `warning`, title: `Parked stack frame: await in tail position of ${entry.method}`, message: `${entry.method} uses \`await this.${entry.callee}()\` followed by \`return\` at line ${entry.awaitLine}. ` + `This parks the caller's stack frame until the callee (and all its recursive descendants) resolve. ` + `Use \`return this.${entry.callee}()\` to release the frame immediately.`, file: filePath, line: entry.awaitLine, locations: [ { file: filePath, line: entry.awaitLine, label: `parked await` }, ], details: entry, })) ) .concat( errorPathPublishReport .filter((entry) => entry.isInErrorPath) .map((entry) => ({ kind: `error-path-publish`, severity: `warning`, title: `Subscriber-facing call in error handler: ${entry.callee} in ${entry.method}`, message: `${entry.method} calls ${entry.callee} at line ${entry.callLine} inside a ` + `${entry.context} block (line ${entry.contextLine}). Publishing messages to subscribers ` + `from error/retry paths can deliver stale or partial data. Error handlers should ` + `clean up and retry, not notify subscribers.`, file: filePath, line: entry.callLine, locations: [ { file: filePath, line: entry.contextLine, label: entry.context }, { file: filePath, line: entry.callLine, label: `subscriber call` }, ], details: entry, })) ) findings.push( ...unboundedRetryReport .filter((report) => !report.hasBoundCheck) .map((report) => ({ kind: `unbounded-retry-loop`, severity: `error`, title: `Recursive call in catch block without detected bound: ${report.method} -> ${report.callee}`, message: `${report.method} calls ${report.callee} inside a catch block at line ${report.callLine} ` + `without a recognized bounding pattern (counter/limit check, error type guard, or abort signal). ` + `This may indicate an unbounded retry loop when errors persist. ` + `Note: this is a heuristic — verify manually if the call is actually bounded by other means.`, file: filePath, line: report.callLine, locations: [ { file: filePath, line: report.callLine, label: `unbounded recursive call`, }, { file: filePath, line: report.catchLine, label: `catch block`, }, ], details: { method: report.method, callee: report.callee, catchLine: report.catchLine, }, })) ) return { sourceFile, classInfo, recursiveMethods, sharedFieldReport, unboundedRetryReport, cacheBusterReport, tailPositionAwaitReport, errorPathPublishReport, findings, } } export function analyzeStateMachine(filePath = STATE_MACHINE_FILE) { const sourceFile = readSourceFile(filePath) const findings = [] const ignoredActionReport = [] for (const statement of sourceFile.statements) { if (!ts.isClassDeclaration(statement) || !statement.name) continue const className = statement.name.text for (const member of statement.members) { if (!ts.isMethodDeclaration(member) || !member.body || !member.name) { continue } const methodName = formatMemberName(member.name) const returnsIgnored = [] walk(member.body, (node) => { if (!ts.isReturnStatement(node) || !node.expression) return if (!ts.isObjectLiteralExpression(node.expression)) return const actionProperty = getObjectLiteralPropertyValue( node.expression, `action` ) if (actionProperty !== `ignored`) return const statePropertyNode = getObjectLiteralPropertyNode( node.expression, `state` ) const stateIsThis = statePropertyNode != null && ts.isPropertyAssignment(statePropertyNode) && statePropertyNode.initializer.kind === ts.SyntaxKind.ThisKeyword returnsIgnored.push({ line: getLine(sourceFile, node), stateIsThis, }) }) if (returnsIgnored.length === 0) continue ignoredActionReport.push({ className, methodName, lines: returnsIgnored.map((entry) => entry.line), }) if (ALLOWED_IGNORED_ACTION_CLASSES.has(className)) continue for (const entry of returnsIgnored) { findings.push({ kind: `ignored-response-transition`, severity: `warning`, title: `Non-delegating state returns ignored action`, message: `${className}.${methodName} returns { action: 'ignored' } outside ` + `the delegate/error states. This is a high-risk pattern for retry loops ` + `when the caller keeps requesting with unchanged URL state.`, file: filePath, line: entry.line, locations: [ { file: filePath, line: entry.line, label: `${className}.${methodName}`, }, ], details: { className, methodName, stateIsThis: entry.stateIsThis, }, }) } } } return { sourceFile, findings, ignoredActionReport, } } export function analyzeProtocolLiterals(filePaths, options = {}) { const findings = [] const report = [] const requireConstantsInFiles = options.requireConstantsInFiles ?? (() => false) for (const filePath of filePaths) { const sourceFile = readSourceFile(filePath) walk(sourceFile, (node) => { const candidate = getProtocolLiteralCandidate(sourceFile, node) if (!candidate) return const requireConstants = requireConstantsInFiles(filePath) const kind = candidate.literal === candidate.canonical ? requireConstants ? `raw-protocol-literal` : null : `protocol-literal-drift` if (!kind) return report.push({ ...candidate, kind, }) findings.push({ kind, severity: `warning`, title: kind === `raw-protocol-literal` ? `Raw Electric protocol literal should use shared constant` : `Near-miss Electric protocol literal: ${candidate.literal}`, message: kind === `raw-protocol-literal` ? `${candidate.literal} is a canonical Electric protocol literal ` + `used directly in implementation code. Import the shared constant ` + `instead to avoid drift between call sites.` : `${candidate.literal} is a near-miss for the canonical Electric ` + `protocol literal ${candidate.canonical}. Use the shared constant ` + `or canonical string to avoid URL/header drift.`, file: filePath, line: candidate.line, locations: [ { file: filePath, line: candidate.line, label: candidate.context, }, ], details: { literal: candidate.literal, canonical: candidate.canonical, context: candidate.context, }, }) }) } return { findings: findings.sort(compareFindings), report: report.sort(compareReports), } } export function loadChangedLines(range, files) { const relativeFiles = files.map((file) => path.relative(GIT_ROOT, file)) const diffOutput = execFileSync( `git`, [`diff`, `--unified=0`, `--no-color`, range, `--`, ...relativeFiles], { cwd: GIT_ROOT, encoding: `utf8`, stdio: [`ignore`, `pipe`, `pipe`], } ) return parseChangedLines(diffOutput) } export function filterFindingsToChangedLines(findings, changedLines) { return findings.filter((finding) => { const locations = finding.locations?.length ? finding.locations : [{ file: finding.file, line: finding.line }] return locations.some((location) => lineIsChanged(changedLines, location.file, location.line) ) }) } export function filterFindingsToChangedFiles(findings, changedLines) { const changedFiles = new Set(changedLines.keys()) return findings.filter((finding) => changedFiles.has(finding.file)) } export function formatAnalysisResult(result, options = {}) { const changedLines = options.changedLines const findings = changedLines ? filterFindingsToChangedLines(result.findings, changedLines) : result.findings const lines = [] lines.push(`Findings: ${findings.length}`) if (findings.length === 0) { lines.push(`No findings.`) } else { for (const finding of findings) { lines.push( `${finding.severity.toUpperCase()} ${finding.kind} ` + `${path.relative(result.packageDir, finding.file)}:${finding.line}` ) lines.push(` ${finding.title}`) lines.push(` ${finding.message}`) } } if (!changedLines) { lines.push(``) lines.push(`Recursive Methods:`) for (const report of result.reports.recursiveMethods) { const cycles = report.callees.length === 0 ? `no internal calls` : report.callees.join(`, `) lines.push( ` ${report.name} (${path.relative(result.packageDir, report.file)}:${report.line}) -> ${cycles}` ) } lines.push(``) lines.push(`Shared Field Candidates:`) for (const report of result.reports.sharedFieldReport) { const flag = report.risky ? `!` : `-` lines.push( ` ${flag} ${report.field}: writers=${report.writerMethods.join(`, `) || `none`} ` + `readers=${report.readerMethods.join(`, `) || `none`}` ) } lines.push(``) lines.push(`Unbounded Retry Report:`) for (const report of result.reports.unboundedRetryReport) { const flag = report.hasBoundCheck ? `-` : `!` lines.push( ` ${flag} ${report.method} -> ${report.callee} ` + `(catch:${report.catchLine} call:${report.callLine}) ` + `bound=${report.boundKind ?? `none`}` ) } lines.push(``) lines.push(`Tail-Position Await Report:`) for (const report of result.reports.tailPositionAwaitReport) { const flag = report.isParked ? `!` : `-` lines.push( ` ${flag} ${report.method} -> ${report.callee} ` + `(line:${report.awaitLine}) ` + `${report.isParked ? `PARKED: use return instead of await` : `ok`}` ) } lines.push(``) lines.push(`Error-Path Publish Report:`) for (const report of result.reports.errorPathPublishReport) { const flag = report.isInErrorPath ? `!` : `-` lines.push( ` ${flag} ${report.method} -> ${report.callee} ` + `(${report.context}:${report.contextLine} call:${report.callLine})` ) } lines.push(``) lines.push(`409 Cache Buster Report:`) for (const report of result.reports.cacheBusterReport) { const flag = report.unconditional ? `-` : `!` let cacheBusterStatus = `missing` if (report.unconditional) { cacheBusterStatus = `unconditional` } else if (report.cacheBusterLine) { cacheBusterStatus = `conditional:${report.cacheBusterLine}` } lines.push( ` ${flag} ${report.method} -> ${report.retryCallee} ` + `(409:${report.statusCheckLine} retry:${report.retryLine}) ` + `cacheBuster=${cacheBusterStatus}` ) } lines.push(``) lines.push(`Ignored Action Sites:`) for (const report of result.reports.ignoredActionReport) { lines.push( ` ${report.className}.${report.methodName} lines ${report.lines.join(`, `)}` ) } lines.push(``) lines.push(`Protocol Literal Sites:`) if (result.reports.protocolLiteralReport.length === 0) { lines.push(` none`) } else { for (const report of result.reports.protocolLiteralReport) { lines.push( ` ${report.kind} ${path.relative(result.packageDir, report.file)}:${report.line} ` + `${report.literal} -> ${report.canonical} (${report.context})` ) } } } return lines.join(`\n`) } function analyzeMethod(sourceFile, methodNames, fieldNames, methodNode) { const name = formatMemberName(methodNode.name) const summary = { name, file: sourceFile.fileName, line: getLine(sourceFile, methodNode.name), async: methodNode.modifiers?.some( (modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword ) ? true : false, public: !name.startsWith(`#`) && name !== `constructor`, calls: [], fieldReads: new Map(), fieldWrites: new Map(), awaits: [], } walk(methodNode.body, (node) => { if (ts.isAwaitExpression(node)) { summary.awaits.push(getLine(sourceFile, node)) return } if (ts.isCallExpression(node)) { const callee = getThisMemberName(node.expression) if (callee && methodNames.has(callee)) { summary.calls.push({ callee, line: getLine(sourceFile, node), }) } return } if (!ts.isPropertyAccessExpression(node)) return const member = getThisMemberName(node) if (!member || methodNames.has(member) || !fieldNames.has(member)) return if (ts.isCallExpression(node.parent) && node.parent.expression === node) { return } const line = getLine(sourceFile, node) if (isWritePosition(node)) { pushMapArray(summary.fieldWrites, member, line) } else { pushMapArray(summary.fieldReads, member, line) } }) return summary } function buildClassInfo(sourceFile, classDecl) { const fieldNames = new Set() const methodNames = new Set() const methods = new Map() for (const member of classDecl.members) { if ( ts.isPropertyDeclaration(member) && member.name && (ts.isIdentifier(member.name) || ts.isPrivateIdentifier(member.name)) ) { fieldNames.add(formatMemberName(member.name)) continue } if ( ts.isGetAccessorDeclaration(member) && member.name && (ts.isIdentifier(member.name) || ts.isPrivateIdentifier(member.name)) ) { fieldNames.add(formatMemberName(member.name)) continue } if ( ts.isMethodDeclaration(member) && member.name && (ts.isIdentifier(member.name) || ts.isPrivateIdentifier(member.name)) ) { methodNames.add(formatMemberName(member.name)) continue } } for (const member of classDecl.members) { if (!ts.isMethodDeclaration(member) || !member.body || !member.name) continue methods.set( formatMemberName(member.name), analyzeMethod(sourceFile, methodNames, fieldNames, member) ) } return { sourceFile, fieldNames, methodNames, methods, } } function buildRecursiveMethodReport(classInfo) { const graph = new Map() for (const [name, method] of classInfo.methods) { graph.set(name, [ ...new Set( method.calls .map((call) => call.callee) .filter((callee) => callee !== name) ), ]) } const recursiveSet = new Set() for (const component of stronglyConnectedComponents(graph)) { if (component.length > 1) { component.forEach((name) => recursiveSet.add(name)) continue } const [single] = component const method = classInfo.methods.get(single) if (method?.calls.some((call) => call.callee === single)) { recursiveSet.add(single) } } return [...classInfo.methods.values()] .filter((method) => recursiveSet.has(method.name)) .map((method) => ({ name: method.name, file: method.file, line: method.line, callees: [...new Set(method.calls.map((call) => call.callee))].sort(), })) .sort(compareReports) } function buildSharedFieldReport(classInfo) { const reports = [] for (const field of [...classInfo.fieldNames].sort()) { if (SHARED_FIELD_IGNORE.has(field)) continue if (!isCandidateEphemeralField(field)) continue const writers = [] const readers = [] const reasons = [] for (const method of classInfo.methods.values()) { const writeLines = method.fieldWrites.get(field) ?? [] const readLines = method.fieldReads.get(field) ?? [] if (writeLines.length > 0) { const hasAsyncBoundary = writeLines.some((line) => hasAsyncBoundaryAfterLine(method, classInfo.methods, line) ) writers.push({ method: method.name, lines: writeLines, hasAsyncBoundary, }) if (hasAsyncBoundary) { reasons.push( `${field} is written in ${method.name} before a later await/async internal call` ) } } if (readLines.length > 0) { readers.push({ method: method.name, lines: readLines, }) } } if (writers.length === 0 && readers.length === 0) continue const writerMethods = writers.map((writer) => writer.method) const readerMethods = readers.map((reader) => reader.method) const writerLines = writers.flatMap((writer) => writer.lines) const readerLines = readers.flatMap((reader) => reader.lines) const crossMethodUse = new Set(writerMethods.concat(readerMethods)).size > 1 const hasRiskyWriter = writers.some((writer) => writer.hasAsyncBoundary) const constructUrlConsumes = readers.some( (reader) => reader.method === `#constructUrl` ) const publicMethodTouches = readers .concat(writers) .some((entry) => !entry.method.startsWith(`#`)) const highRiskField = /(?:Buster|Retry)/.test(field) if (constructUrlConsumes) { reasons.push( `${field} is consumed by #constructUrl, which multiple paths call` ) } if (publicMethodTouches) { reasons.push(`${field} is reachable from a public API surface`) } reports.push({ field, risky: crossMethodUse && hasRiskyWriter && (constructUrlConsumes || highRiskField), primaryLine: writerLines[0] ?? readerLines[0], writerMethods, readerMethods, writerLines, readerLines, reasons: [...new Set(reasons)].sort(), }) } return reports.sort(compareReports) } function buildUnboundedRetryReport(sourceFile, classDecl, recursiveMethods) { const report = [] const recursiveNames = new Set(recursiveMethods.map((m) => m.name)) for (const member of classDecl.members) { if (!ts.isMethodDeclaration(member) || !member.body || !member.name) continue const methodName = formatMemberName(member.name) if (!recursiveNames.has(methodName)) continue walk(member.body, (node) => { if (!ts.isCatchClause(node)) return const catchLine = getLine(sourceFile, node) walk(node.block, (inner) => { if (!ts.isCallExpression(inner)) return const callee = getThisMemberName(inner.expression) if (!callee || !recursiveNames.has(callee)) return const callLine = getLine(sourceFile, inner) const boundKind = classifyRetryBound(sourceFile, node, inner) report.push({ method: methodName, callee, callLine, catchLine, hasBoundCheck: boundKind !== null, boundKind, }) }) }) } return report.sort(compareReports) } /** * Finds all 409 status handlers in the ShapeStream class and verifies that * each one unconditionally calls createCacheBuster(). A 409 retry without * a cache buster risks producing identical retry URLs when the server * returns the same handle, causing infinite CDN-cached retry loops. */ function build409CacheBusterReport(sourceFile, classDecl) { const report = [] for (const member of classDecl.members) { if (!ts.isMethodDeclaration(member) || !member.body || !member.name) continue const methodName = formatMemberName(member.name) walk(member.body, (node) => { // Look for if-statements that check e.status == 409 or e.status === 409 if (!ts.isIfStatement(node)) return if (!is409StatusCheck(node.expression)) return const statusCheckLine = getLine(sourceFile, node) const block = node.thenStatement // Find retry calls (recursive this.# calls or return this.# calls) const retryCalls = [] walk(block, (inner) => { if (!ts.isCallExpression(inner)) return const callee = getThisMemberName(inner.expression) if (callee) { retryCalls.push({ callee, line: getLine(sourceFile, inner), }) } }) if (retryCalls.length === 0) return // Find all createCacheBuster() calls in the 409 block const cacheBusterCalls = [] walk(block, (inner) => { if ( ts.isCallExpression(inner) && ts.isIdentifier(inner.expression) && inner.expression.text === `createCacheBuster` ) { cacheBusterCalls.push({ line: getLine(sourceFile, inner), conditional: isInsideIfBlock(inner, block), }) } }) const lastRetry = retryCalls[retryCalls.length - 1] const hasUnconditional = cacheBusterCalls.some((c) => !c.conditional) report.push({ method: methodName, statusCheckLine, retryCallee: lastRetry.callee, retryLine: lastRetry.line, cacheBusterLine: cacheBusterCalls.length > 0 ? cacheBusterCalls[0].line : null, unconditional: hasUnconditional, }) }) } return report.sort(compareReports) } /** * Finds `await this.#method()` calls in tail position of recursive methods. * A tail-position await parks the caller's stack frame until the callee resolves, * causing O(n) suspended frames for n recursive iterations. Using `return this.#method()` * instead releases the frame immediately via promise chaining. * * Detects two patterns: * 1. `await this.#foo(); return` (explicit return after await) * 2. `await this.#foo()` as the last statement in a block (implicit return) */ function buildTailPositionAwaitReport(sourceFile, classDecl, recursiveMethods) { const report = [] const recursiveNames = new Set(recursiveMethods.map((m) => m.name)) for (const member of classDecl.members) { if (!ts.isMethodDeclaration(member) || !member.body || !member.name) continue const methodName = formatMemberName(member.name) if (!recursiveNames.has(methodName)) continue walk(member.body, (node) => { if (!ts.isAwaitExpression(node)) return const awaitExpr = node.expression if (!ts.isCallExpression(awaitExpr)) return const callee = getThisMemberName(awaitExpr.expression) if (!callee) return // Check if this await is immediately followed by a bare `return` in // its block. This pattern — `await this.#foo(); return` — parks the // caller's stack frame while the callee resolves. It should be // `return this.#foo()` to release the frame via promise chaining. const exprStmt = node.parent if (!ts.isExpressionStatement(exprStmt)) return const block = exprStmt.parent if (!ts.isBlock(block)) return const stmtIndex = block.statements.indexOf(exprStmt) if (stmtIndex === -1) return const next = block.statements[stmtIndex + 1] const followedByBareReturn = next && ts.isReturnStatement(next) && !next.expression if (!followedByBareReturn) return // Only flag as parked if the await is NOT inside a try block's try clause. // Inside try { await ... } catch { ... }, the await is needed for the catch // to handle errors. In catch/finally blocks or outside try-catch, the await // parks the caller's frame unnecessarily. const isParked = !isInsideTryClause(node, member.body) report.push({ method: methodName, callee, awaitLine: getLine(sourceFile, node), isParked, }) }) } return report.sort(compareReports) } /** * Detects calls to subscriber-facing methods (#publish, #onMessages) inside * error handling paths (catch blocks, status-check if-branches that throw/retry). * Publishing messages from error handlers can deliver stale or partial data to * subscribers — the fix for Bug #4 removed exactly this pattern from the 409 handler. */ const SUBSCRIBER_METHODS = new Set([`#publish`, `#onMessages`]) function buildErrorPathPublishReport(sourceFile, classDecl) { const report = [] for (const member of classDecl.members) { if (!ts.isMethodDeclaration(member) || !member.body || !member.name) continue const methodName = formatMemberName(member.name) // Check catch blocks walk(member.body, (node) => { if (ts.isCatchClause(node)) { walk(node.block, (inner) => { if (!ts.isCallExpression(inner)) return const callee = getThisMemberName(inner.expression) if (!callee || !SUBSCRIBER_METHODS.has(callee)) return if (isStaticControlMessagePublish(inner)) return report.push({ method: methodName, callee, callLine: getLine(sourceFile, inner), context: `catch`, contextLine: getLine(sourceFile, node), isInErrorPath: true, }) }) return } // Check 4xx/5xx status-check if-blocks (e.g., if (e.status == 409)) if (ts.isIfStatement(node) && isHttpErrorStatusCheck(node.expression)) { walk(node.thenStatement, (inner) => { if (!ts.isCallExpression(inner)) return const callee = getThisMemberName(inner.expression) if (!callee || !SUBSCRIBER_METHODS.has(callee)) return if (isStaticControlMessagePublish(inner)) return report.push({ method: methodName, callee, callLine: getLine(sourceFile, inner), context: `status-${getStatusLiteral(node.expression)}`, contextLine: getLine(sourceFile, node), isInErrorPath: true, }) }) } }) } return report.sort(compareReports) } /** * Returns true iff a call expression is a #publish([{ headers: { control: <string literal> } }, ...]) * with a non-empty static array where every element is an object literal whose * `headers.control` is a string literal. These synthetic control-only publishes * are intentional (e.g., the must-refetch notification in the 409 handler) and * should not trigger the error-path-publish rule. A data-row publish * (headers.operation = 'insert' etc.) is NOT exempt and must be flagged. */ function isStaticControlMessagePublish(callExpr) { const [firstArg] = callExpr.arguments if (!firstArg || !ts.isArrayLiteralExpression(firstArg)) return false if (firstArg.elements.length === 0) return false return firstArg.elements.every((el) => { if (!ts.isObjectLiteralExpression(el)) return false const headersProp = el.properties.find( (p) => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === `headers` ) if (!headersProp || !ts.isObjectLiteralExpression(headersProp.initializer)) return false return headersProp.initializer.properties.some( (p) => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === `control` && (ts.isStringLiteral(p.initializer) || ts.isNoSubstitutionTemplateLiteral(p.initializer)) ) }) } /** * Returns true if the expression checks an HTTP error status (4xx or 5xx). * Recurses into && and || expressions. */ function isHttpErrorStatusCheck(expression) { if (!ts.isBinaryExpression(expression)) return false const op = expression.operatorToken.kind if ( op === ts.SyntaxKind.EqualsEqualsToken || op === ts.SyntaxKind.EqualsEqualsEqualsToken ) { return ( (isHttpErrorLiteral(expression.left) && isStatusAccess(expression.right)) || (isHttpErrorLiteral(expression.right) && isStatusAccess(expression.left)) ) } if ( op === ts.SyntaxKind.AmpersandAmpersandToken || op === ts.SyntaxKind.BarBarToken ) { return ( isHttpErrorStatusCheck(expression.left) || isHttpErrorStatusCheck(expression.right) ) } return false } function isHttpErrorLiteral(node) { if (!ts.isNumericLiteral(node)) return false const status = Number(node.text) return status >= 400 && status < 600 } function getStatusLiteral(expression) { if (!ts.isBinaryExpression(expression)) return `unknown` const op = expression.operatorToken.kind if ( op === ts.SyntaxKind.EqualsEqualsToken || op === ts.SyntaxKind.EqualsEqualsEqualsToken ) { if (ts.isNumericLiteral(expression.left)) return expression.left.text if (ts.isNumericLiteral(expression.right)) return expression.right.text } if ( op === ts.SyntaxKind.AmpersandAmpersandToken || op === ts.SyntaxKind.BarBarToken ) { const left = getStatusLiteral(expression.left) if (left !== `unknown`) return left return getStatusLiteral(expression.right) } return `unknown` } /** * Returns true if the node is inside the try clause (not catch/finally) of a * TryStatement that is a descendant of the given boundary node. */ function isInsideTryClause(node, boundary) { let current = node.parent while (current && current !== boundary) { if (ts.isTryStatement(current.parent)) { // Check if `current` is the tryBlock (not catchClause or finallyBlock) if (current === current.parent.tryBlock) return true } current = current.parent } return false } /** * Returns true if the expression contains a `.status == 409` or `.status === 409` check. * Recurses into `&&` and `||` expressions to handle compound conditions like * `e instanceof FetchError && e.status === 409`. */ function is409StatusCheck(expression) { if (!ts.isBinaryExpression(expression)) return false const op = expression.operatorToken.kind if ( op === ts.SyntaxKind.EqualsEqualsToken || op === ts.SyntaxKind.EqualsEqualsEqualsToken ) { return ( (is409Literal(expression.left) && isStatusAccess(expression.right)) || (is409Literal(expression.right) && isStatusAccess(expression.left)) ) } if ( op === ts.SyntaxKind.AmpersandAmpersandToken || op === ts.SyntaxKind.BarBarToken ) { return ( is409StatusCheck(expression.left) || is409StatusCheck(expression.right) ) } return false } function is409Literal(node) { return ts.isNumericLiteral(node) && node.text === `409` } function isStatusAccess(node) { return ts.isPropertyAccessExpression(node) && node.name.text === `status` } /** * Returns true if a node is inside an if-statement's then or else block * within the given boundary node (the 409 handler block). */ function isInsideIfBlock(node, boundary) { let current = node.parent while (current && current !== boundary) { const parent = current.parent if ( parent && ts.isIfStatement(parent) && (current === parent.thenStatement || current === parent.elseStatement) ) { return true } current = current.parent } return false } function classifyRetryBound(sourceFile, catchClause, callNode) { const callLine = getLine(sourceFile, callNode) const enclosingConditions = collectEnclosingIfConditions( catchClause, callNode ) if (findPriorCounterGuard(sourceFile, catchClause.block, callLine)) { return `counter` } for (const condition of enclosingConditions) { if (containsThisFieldComparison(condition)) return `counter` } for (const condition of enclosingConditions) { if (containsInstanceof(condition) || containsPropertyEquality(condition)) { return `type-guard` } } for (const condition of enclosingConditions) { if (containsAbortedAccess(condition)) return `abort-signal` } if (enclosingConditions.length > 0) return `callback-gate` return null } function collectEnclosingIfConditions(catchClause, callNode) { const conditions = [] let current = callNode.parent while (current && current !== catchClause) { const parent = current.parent if ( parent && ts.isIfStatement(parent) && current === parent.thenStatement ) { conditions.push(parent.expression) } current = current.parent } return conditions } function findPriorCounterGuard(sourceFile, block, beforeLine) { return walkSome( block, (node) => ts.isIfStatement(node) && getLine(sourceFile, node) < beforeLine && containsThisFieldComparison(node.expression) && containsExitStatement(node.thenStatement) ) } /** Walks a node tree and returns true if predicate matches any descendant. */ function walkSome(root, predicate) { let found = false walk(root, (node) => { if (found) return if (predicate(node)) found = true }) return found } function containsInstanceof(node) { return walkSome( node, (inner) => ts.isBinaryExpression(inner) && inner.operatorToken.kind === ts.SyntaxKind.InstanceOfKeyword ) } function containsPropertyEquality(node) { return walkSome(node, (inner) => { if (!ts.isBinaryExpression(inner)) return false const op = inner.operatorToken.kind if ( op !== ts.SyntaxKind.EqualsEqualsToken && op !== ts.SyntaxKind.EqualsEqualsEqualsToken ) { return false } return ( ts.isPropertyAccessExpression(inner.left) || ts.isPropertyAccessExpression(inner.right) ) }) } function containsAbortedAccess(node) { return walkSome( node, (inner) => ts.isPropertyAccessExpression(inner) && inner.name.text === `aborted` ) } function containsThisFieldComparison(node) { return walkSome(node, (inner) => { if (!ts.isBinaryExpression(inner)) return false const op = inner.operatorToken.kind if ( op !== ts.SyntaxKind.GreaterThanToken && op !== ts.SyntaxKind.GreaterThanEqualsToken && op !== ts.SyntaxKind.LessThanToken && op !== ts.SyntaxKind.LessThanEqualsToken ) { return false } return isThisFieldAccess(inner.left) || isThisFieldAccess(inner.right) }) } function isThisFieldAccess(node) { return ( ts.isPropertyAccessExpression(node) && node.expression.kind === ts.SyntaxKind.ThisKeyword ) } function containsExitStatement(node) { return walkSome( node, (inner) => ts.isReturnStatement(inner) || ts.isThrowStatement(inner) ) } function stronglyConnectedComponents(graph) { let index = 0 const stack = [] const indices = new Map() const lowLinks = new Map() const onStack = new Set() const components = [] const visit = (node) => { indices.set(node, index) lowLinks.set(node, index) index += 1 stack.push(node) onStack.add(node) for (const neighbor of graph.get(node) ?? []) { if (!indices.has(neighbor)) { visit(neighbor) lowLinks.set(node, Math.min(lowLinks.get(node), lowLinks.get(neighbor))) } else if (onStack.has(neighbor)) { lowLinks.set(node, Math.min(lowLinks.get(node), indices.get(neighbor))) } } if (lowLinks.get(node) !== indices.get(node)) return const component = [] while (stack.length > 0) { const current = stack.pop() onStack.delete(current) component.push(current) if (current === node) break } components.push(component.sort()) } for (const node of graph.keys()) { if (!indices.has(node)) visit(node) } return components } function parseChangedLines(diffOutput) { const changedLines = new Map() let currentFile for (const line of diffOutput.split(`\n`)) { if (line.startsWith(`+++ b/`)) { currentFile = path.join(GIT_ROOT, line.slice(6)) continue } if (!line.startsWith(`@@`) || !currentFile) continue const match = /@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line) if (!match) continue const start = Number(match[1]) const count = Number(match[2] ?? `1`) const lines = changedLines.get(currentFile) ?? new Set() const end = count === 0 ? start : start + count - 1 for (let lineNumber = start; lineNumber <= end; lineNumber += 1) { lines.add(lineNumber) } changedLines.set(currentFile, lines) } return changedLines } function listAnalysisFiles(packageDir) { const filePaths = [] for (const relativeDir of ANALYSIS_DIRS) { const absoluteDir = path.join(packageDir, relativeDir) if (!fs.existsSync(absoluteDir)) continue walkDirectory(absoluteDir, filePaths) } return filePaths.sort() } function walkDirectory(directory, filePaths) { for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { if (entry.name.startsWith(`.`)) continue if (ANALYSIS_EXCLUDED_DIRS.has(entry.name)) continue const absolutePath = path.join(directory, entry.name) if (entry.isDirectory()) { walkDirectory(absolutePath, filePaths) continue } if (!ANALYSIS_EXTENSIONS.has(path.extname(entry.name))) continue if (absolutePath === path.join(PACKAGE_DIR, `src`, `constants.ts`)) continue filePaths.push(absolutePath) } } function lineIsChanged(changedLines, file, line) { return changedLines.get(file)?.has(line) ?? false } function readSourceFile(filePath) { const text = fs.readFileSync(filePath, `utf8`) return ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true) } function getProtocolLiteralCandidate(sourceFile, node) { if (ts.isCallExpression(node)) { return getProtocolLiteralCandidateFromCall(sourceFile, node) } if (isProtocolHeaderProperty(node)) { return getProtocolLiteralCandidateFromHeaderProperty(sourceFile, node) } if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { return getProtocolLiteralCandidateFromLiteral(sourceFile, node) } return null } function getProtocolLiteralCandidateFromCall(sourceFile, callExpression) { if (!ts.isPropertyAccessExpression(callExpression.expression)) return null if (!PROTOCOL_LITERAL_METHODS.has(callExpression.expression.name.text)) { return null } const [firstArg] = callExpression.arguments const literal = getStringLiteralValue(firstArg) if (!literal) return null const receiver = callExpression.expression.expression const receiverContext = getProtocolReceiverContext(receiver) if (!receiverContext) return null return createProtocolLiteralCandidate( sourceFile, firstArg, literal, `${receiverContext}.${callExpression.expression.name.text}` ) } function getProtocolLiteralCandidateFromHeaderProperty( sourceFile, propertyNode ) { const literal = getPropertyNameValue(propertyNode.name) if (!literal) return null return createProtocolLiteralCandidate( sourceFile, propertyNode.name, literal, `headers object property` ) } function getProtocolLiteralCandidateFromLiteral(sourceFile, node) { const literal = node.text if (!PROTOCOL_LITERAL_CANONICAL_VALUES.includes(literal)) return null const parent = node.parent if (!ts.isArrayLiteralExpression(parent)) return null if (!isProtocolLiteralArray(parent)) return null return createProtocolLiteralCandidate( sourceFile, node, literal, `protocol literal array` ) } function createProtocolLiteralCandidate(sourceFile, node, literal, context) { const canonical = PROTOCOL_LITERAL_BY_NORMALIZED.get( normalizeLiteral(literal) ) if (!canonical) return null return { file: sourceFile.fileName, line: getLine(sourceFile, node), literal, canonical, context, } } function getProtocolReceiverContext(receiver) { if (ts.isPropertyAccessExpression(receiver)) { if (receiver.name.text === `searchParams`) return `searchParams` if (receiver.name.text === `headers`) return `headers` } if (ts.isIdentifier(receiver) && receiver.text === `headers`) { return `headers` } return null } function isHeadersObjectLiteral(node) { const parent = node.parent if ( ts.isPropertyAssignment(parent) && getPropertyNameValue(parent.name) === `headers` ) { return true } if ( ts.isNewExpression(parent) && ts.isIdentifier(parent.expression) && parent.expression.text === `Headers` ) { return true } return false } function isProtocolHeaderProperty(node) { if (!ts.isPropertyAssignment(node) || !node.name) return false if (!ts.isObjectLiteralExpression(node.parent)) return false return isHeadersObjectLiteral(node.parent) } function isProtocolLiteralArray(node) { const parent = node.parent if (!ts.isVariableDeclaration(parent) || !ts.isIdentifier(parent.name)) { return false } return /(?:Header|Param)s$/.test(parent.name.text) } function walk(node, visit) { visit(node) ts.forEachChild(node, (child) => walk(child, visit)) } function getThisMemberName(node) { if (!ts.isPropertyAccessExpression(node)) return null if (node.expression.kind !== ts.SyntaxKind.ThisKeyword) return null return formatMemberName(node.name) } function isWritePosition(node) { const parent = node.parent if (!parent) return false if ( ts.isBinaryExpression(parent) && parent.left === node && parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment ) { return true } if ( (ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) && (parent.operator === ts.SyntaxKind.PlusPlusToken || parent.operator === ts.SyntaxKind.MinusMinusToken) ) { return true } return false } function getLine(sourceFile, node) { return ( sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1 ) } function formatMemberName(nameNode) { if (ts.