@electric-sql/client
Version:
Postgres everywhere - your data, in sync, wherever you need it.
1,685 lines (1,477 loc) • 52.9 kB
JavaScript
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.