@electric-sql/client
Version:
Postgres everywhere - your data, in sync, wherever you need it.
1,068 lines (917 loc) • 28.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,
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 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,
},
}))
return {
sourceFile,
classInfo,
recursiveMethods,
sharedFieldReport,
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(`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 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.isPrivateIdentifier(nameNode)) {
return nameNode.text.startsWith(`#`) ? nameNode.text : `#${nameNode.text}`
}
if (
ts.isIdentifier(nameNode) ||
ts.isStringLiteral(nameNode) ||
ts.isNumericLiteral(nameNode)
) {
return `${nameNode.text}`
}
return nameNode.getText()
}
function getPropertyNameValue(nameNode) {
if (
ts.isIdentifier(nameNode) ||
ts.isStringLiteral(nameNode) ||
ts.isNoSubstitutionTemplateLiteral(nameNode)
) {
return nameNode.text
}
return null
}
function getStringLiteralValue(node) {
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
return node.text
}
return null
}
function hasAsyncBoundaryAfterLine(method, methods, line) {
if (method.awaits.some((awaitLine) => awaitLine > line)) return true
return method.calls.some((call) => {
if (call.line <= line) return false
return methods.get(call.callee)?.async ?? false
})
}
function isCandidateEphemeralField(field) {
return /(?:Buster|Retry|Count|Counter|Recent|AbortController|Promise|Refresh|Duplicate)/.test(
field
)
}
function pushMapArray(map, key, value) {
const values = map.get(key)
if (values) {
values.push(value)
return
}
map.set(key, [value])
}
function uniqueLocations(locations) {
const seen = new Set()
const result = []
for (const location of locations) {
const key = `${location.file}:${location.line}:${location.label ?? ``}`
if (seen.has(key)) continue
seen.add(key)
result.push(location)
}
return result
}
function getObjectLiteralPropertyNode(objectLiteral, propertyName) {
return objectLiteral.properties.find((property) => {
if (!ts.isPropertyAssignment(property) || !property.name) return false
return formatMemberName(property.name) === propertyName
})
}
function getObjectLiteralPropertyValue(objectLiteral, propertyName) {
const property = getObjectLiteralPropertyNode(objectLiteral, propertyName)
if (!property || !ts.isPropertyAssignment(property)) return undefined
if (ts.isStringLiteral(property.initializer)) return property.initializer.text
return undefined
}
function compareFindings(left, right) {
const fileCompare = left.file.localeCompare(right.file)
if (fileCompare !== 0) return fileCompare
return left.line - right.line
}
function compareReports(left, right) {
const leftLine = left.line ?? left.primaryLine ?? 0
const rightLine = right.line ?? right.primaryLine ?? 0
if (leftLine !== rightLine) return leftLine - rightLine
return (left.file ?? ``).localeCompare(right.file ?? ``)
}
function resolveGitRoot() {
try {
return execFileSync(`git`, [`rev-parse`, `--show-toplevel`], {
cwd: PACKAGE_DIR,
encoding: `utf8`,
stdio: [`ignore`, `pipe`, `ignore`],
}).trim()
} catch {
return PACKAGE_DIR
}
}
function normalizeLiteral(value) {
return value.toLowerCase().replace(/[^a-z0-9]/g, ``)
}