@electric-sql/client 1.5.11 → 1.5.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1067 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { execFileSync } from 'node:child_process'
4
+ import { fileURLToPath } from 'node:url'
5
+ import ts from 'typescript'
6
+
7
+ const PACKAGE_DIR = path.resolve(
8
+ path.dirname(fileURLToPath(import.meta.url)),
9
+ `..`,
10
+ `..`
11
+ )
12
+ const GIT_ROOT = resolveGitRoot()
13
+
14
+ const CLIENT_FILE = path.join(PACKAGE_DIR, `src`, `client.ts`)
15
+ const STATE_MACHINE_FILE = path.join(
16
+ PACKAGE_DIR,
17
+ `src`,
18
+ `shape-stream-state.ts`
19
+ )
20
+ const ANALYSIS_DIRS = [`src`, `test`, `bin`]
21
+ const ANALYSIS_EXTENSIONS = new Set([`.ts`, `.tsx`, `.js`, `.mjs`])
22
+ const ANALYSIS_EXCLUDED_DIRS = new Set([
23
+ `dist`,
24
+ `node_modules`,
25
+ `junit`,
26
+ `coverage`,
27
+ `fixtures`,
28
+ ])
29
+ const PROTOCOL_LITERAL_METHODS = new Set([
30
+ `get`,
31
+ `set`,
32
+ `has`,
33
+ `append`,
34
+ `delete`,
35
+ ])
36
+ const PROTOCOL_LITERAL_CANONICAL_VALUES = [
37
+ `electric-cursor`,
38
+ `electric-handle`,
39
+ `electric-offset`,
40
+ `electric-schema`,
41
+ `electric-up-to-date`,
42
+ `cursor`,
43
+ `expired_handle`,
44
+ `handle`,
45
+ `live`,
46
+ `offset`,
47
+ `table`,
48
+ `where`,
49
+ `replica`,
50
+ `params`,
51
+ `experimental_live_sse`,
52
+ `live_sse`,
53
+ `log`,
54
+ `subset__where`,
55
+ `subset__limit`,
56
+ `subset__offset`,
57
+ `subset__order_by`,
58
+ `subset__params`,
59
+ `subset__where_expr`,
60
+ `subset__order_by_expr`,
61
+ `cache-buster`,
62
+ ]
63
+ const PROTOCOL_LITERAL_BY_NORMALIZED = new Map(
64
+ PROTOCOL_LITERAL_CANONICAL_VALUES.map((value) => [
65
+ normalizeLiteral(value),
66
+ value,
67
+ ])
68
+ )
69
+
70
+ const SHARED_FIELD_IGNORE = new Set([
71
+ `#syncState`,
72
+ `#started`,
73
+ `#connected`,
74
+ `#error`,
75
+ `#messageChain`,
76
+ `#onError`,
77
+ `#mode`,
78
+ `#pauseLock`,
79
+ `#subscribers`,
80
+ `#snapshotTracker`,
81
+ `#snapshotCounter`,
82
+ `#unsubscribeFromVisibilityChanges`,
83
+ `#unsubscribeFromWakeDetection`,
84
+ `#transformer`,
85
+ `#currentFetchUrl`,
86
+ `#tickPromise`,
87
+ ])
88
+
89
+ const ALLOWED_IGNORED_ACTION_CLASSES = new Set([`ErrorState`, `PausedState`])
90
+
91
+ export function analyzeTypeScriptClient(options = {}) {
92
+ const packageDir = options.packageDir ?? PACKAGE_DIR
93
+ const clientFile = path.join(packageDir, `src`, `client.ts`)
94
+ const stateMachineFile = path.join(packageDir, `src`, `shape-stream-state.ts`)
95
+
96
+ const clientAnalysis = analyzeShapeStreamClient(clientFile)
97
+ const stateMachineAnalysis = analyzeStateMachine(stateMachineFile)
98
+ const protocolLiteralAnalysis = analyzeProtocolLiterals(
99
+ listAnalysisFiles(packageDir),
100
+ {
101
+ requireConstantsInFiles: (filePath) =>
102
+ filePath.includes(`${path.sep}src${path.sep}`),
103
+ }
104
+ )
105
+
106
+ const findings = clientAnalysis.findings
107
+ .concat(stateMachineAnalysis.findings)
108
+ .concat(protocolLiteralAnalysis.findings)
109
+ .sort(compareFindings)
110
+
111
+ return {
112
+ packageDir,
113
+ clientFile,
114
+ stateMachineFile,
115
+ findings,
116
+ reports: {
117
+ recursiveMethods: clientAnalysis.recursiveMethods,
118
+ sharedFieldReport: clientAnalysis.sharedFieldReport,
119
+ ignoredActionReport: stateMachineAnalysis.ignoredActionReport,
120
+ protocolLiteralReport: protocolLiteralAnalysis.report,
121
+ },
122
+ }
123
+ }
124
+
125
+ export function analyzeShapeStreamClient(filePath = CLIENT_FILE) {
126
+ const sourceFile = readSourceFile(filePath)
127
+ const classDecl = sourceFile.statements.find(
128
+ (statement) =>
129
+ ts.isClassDeclaration(statement) && statement.name?.text === `ShapeStream`
130
+ )
131
+
132
+ if (!classDecl) {
133
+ throw new Error(`Could not find ShapeStream class in ${filePath}`)
134
+ }
135
+
136
+ const classInfo = buildClassInfo(sourceFile, classDecl)
137
+ const recursiveMethods = buildRecursiveMethodReport(classInfo)
138
+ const sharedFieldReport = buildSharedFieldReport(classInfo)
139
+ const findings = sharedFieldReport
140
+ .filter((report) => report.risky)
141
+ .map((report) => ({
142
+ kind: `shared-instance-field`,
143
+ severity: `warning`,
144
+ title: `Shared mutable field spans async boundaries: ${report.field}`,
145
+ message:
146
+ `${report.field} is written before an await or async internal call and ` +
147
+ `is also consumed by other methods. This can leak retry/cache-buster state ` +
148
+ `across concurrent call chains.`,
149
+ file: filePath,
150
+ line: report.primaryLine,
151
+ locations: uniqueLocations([
152
+ {
153
+ file: filePath,
154
+ line: report.primaryLine,
155
+ label: `first async write`,
156
+ },
157
+ ...report.writerLines.map((line) => ({
158
+ file: filePath,
159
+ line,
160
+ label: `writer`,
161
+ })),
162
+ ...report.readerLines.map((line) => ({
163
+ file: filePath,
164
+ line,
165
+ label: `reader/reset`,
166
+ })),
167
+ ]),
168
+ details: {
169
+ field: report.field,
170
+ writerMethods: report.writerMethods,
171
+ readerMethods: report.readerMethods,
172
+ reasons: report.reasons,
173
+ },
174
+ }))
175
+
176
+ return {
177
+ sourceFile,
178
+ classInfo,
179
+ recursiveMethods,
180
+ sharedFieldReport,
181
+ findings,
182
+ }
183
+ }
184
+
185
+ export function analyzeStateMachine(filePath = STATE_MACHINE_FILE) {
186
+ const sourceFile = readSourceFile(filePath)
187
+ const findings = []
188
+ const ignoredActionReport = []
189
+
190
+ for (const statement of sourceFile.statements) {
191
+ if (!ts.isClassDeclaration(statement) || !statement.name) continue
192
+
193
+ const className = statement.name.text
194
+ for (const member of statement.members) {
195
+ if (!ts.isMethodDeclaration(member) || !member.body || !member.name) {
196
+ continue
197
+ }
198
+
199
+ const methodName = formatMemberName(member.name)
200
+ const returnsIgnored = []
201
+
202
+ walk(member.body, (node) => {
203
+ if (!ts.isReturnStatement(node) || !node.expression) return
204
+ if (!ts.isObjectLiteralExpression(node.expression)) return
205
+
206
+ const actionProperty = getObjectLiteralPropertyValue(
207
+ node.expression,
208
+ `action`
209
+ )
210
+ if (actionProperty !== `ignored`) return
211
+
212
+ const statePropertyNode = getObjectLiteralPropertyNode(
213
+ node.expression,
214
+ `state`
215
+ )
216
+ const stateIsThis =
217
+ statePropertyNode != null &&
218
+ ts.isPropertyAssignment(statePropertyNode) &&
219
+ statePropertyNode.initializer.kind === ts.SyntaxKind.ThisKeyword
220
+
221
+ returnsIgnored.push({
222
+ line: getLine(sourceFile, node),
223
+ stateIsThis,
224
+ })
225
+ })
226
+
227
+ if (returnsIgnored.length === 0) continue
228
+
229
+ ignoredActionReport.push({
230
+ className,
231
+ methodName,
232
+ lines: returnsIgnored.map((entry) => entry.line),
233
+ })
234
+
235
+ if (ALLOWED_IGNORED_ACTION_CLASSES.has(className)) continue
236
+
237
+ for (const entry of returnsIgnored) {
238
+ findings.push({
239
+ kind: `ignored-response-transition`,
240
+ severity: `warning`,
241
+ title: `Non-delegating state returns ignored action`,
242
+ message:
243
+ `${className}.${methodName} returns { action: 'ignored' } outside ` +
244
+ `the delegate/error states. This is a high-risk pattern for retry loops ` +
245
+ `when the caller keeps requesting with unchanged URL state.`,
246
+ file: filePath,
247
+ line: entry.line,
248
+ locations: [
249
+ {
250
+ file: filePath,
251
+ line: entry.line,
252
+ label: `${className}.${methodName}`,
253
+ },
254
+ ],
255
+ details: {
256
+ className,
257
+ methodName,
258
+ stateIsThis: entry.stateIsThis,
259
+ },
260
+ })
261
+ }
262
+ }
263
+ }
264
+
265
+ return {
266
+ sourceFile,
267
+ findings,
268
+ ignoredActionReport,
269
+ }
270
+ }
271
+
272
+ export function analyzeProtocolLiterals(filePaths, options = {}) {
273
+ const findings = []
274
+ const report = []
275
+ const requireConstantsInFiles =
276
+ options.requireConstantsInFiles ?? (() => false)
277
+
278
+ for (const filePath of filePaths) {
279
+ const sourceFile = readSourceFile(filePath)
280
+
281
+ walk(sourceFile, (node) => {
282
+ const candidate = getProtocolLiteralCandidate(sourceFile, node)
283
+ if (!candidate) return
284
+
285
+ const requireConstants = requireConstantsInFiles(filePath)
286
+ const kind =
287
+ candidate.literal === candidate.canonical
288
+ ? requireConstants
289
+ ? `raw-protocol-literal`
290
+ : null
291
+ : `protocol-literal-drift`
292
+
293
+ if (!kind) return
294
+
295
+ report.push({
296
+ ...candidate,
297
+ kind,
298
+ })
299
+
300
+ findings.push({
301
+ kind,
302
+ severity: `warning`,
303
+ title:
304
+ kind === `raw-protocol-literal`
305
+ ? `Raw Electric protocol literal should use shared constant`
306
+ : `Near-miss Electric protocol literal: ${candidate.literal}`,
307
+ message:
308
+ kind === `raw-protocol-literal`
309
+ ? `${candidate.literal} is a canonical Electric protocol literal ` +
310
+ `used directly in implementation code. Import the shared constant ` +
311
+ `instead to avoid drift between call sites.`
312
+ : `${candidate.literal} is a near-miss for the canonical Electric ` +
313
+ `protocol literal ${candidate.canonical}. Use the shared constant ` +
314
+ `or canonical string to avoid URL/header drift.`,
315
+ file: filePath,
316
+ line: candidate.line,
317
+ locations: [
318
+ {
319
+ file: filePath,
320
+ line: candidate.line,
321
+ label: candidate.context,
322
+ },
323
+ ],
324
+ details: {
325
+ literal: candidate.literal,
326
+ canonical: candidate.canonical,
327
+ context: candidate.context,
328
+ },
329
+ })
330
+ })
331
+ }
332
+
333
+ return {
334
+ findings: findings.sort(compareFindings),
335
+ report: report.sort(compareReports),
336
+ }
337
+ }
338
+
339
+ export function loadChangedLines(range, files) {
340
+ const relativeFiles = files.map((file) => path.relative(GIT_ROOT, file))
341
+ const diffOutput = execFileSync(
342
+ `git`,
343
+ [`diff`, `--unified=0`, `--no-color`, range, `--`, ...relativeFiles],
344
+ {
345
+ cwd: GIT_ROOT,
346
+ encoding: `utf8`,
347
+ stdio: [`ignore`, `pipe`, `pipe`],
348
+ }
349
+ )
350
+
351
+ return parseChangedLines(diffOutput)
352
+ }
353
+
354
+ export function filterFindingsToChangedLines(findings, changedLines) {
355
+ return findings.filter((finding) => {
356
+ const locations = finding.locations?.length
357
+ ? finding.locations
358
+ : [{ file: finding.file, line: finding.line }]
359
+
360
+ return locations.some((location) =>
361
+ lineIsChanged(changedLines, location.file, location.line)
362
+ )
363
+ })
364
+ }
365
+
366
+ export function filterFindingsToChangedFiles(findings, changedLines) {
367
+ const changedFiles = new Set(changedLines.keys())
368
+ return findings.filter((finding) => changedFiles.has(finding.file))
369
+ }
370
+
371
+ export function formatAnalysisResult(result, options = {}) {
372
+ const changedLines = options.changedLines
373
+ const findings = changedLines
374
+ ? filterFindingsToChangedLines(result.findings, changedLines)
375
+ : result.findings
376
+
377
+ const lines = []
378
+ lines.push(`Findings: ${findings.length}`)
379
+
380
+ if (findings.length === 0) {
381
+ lines.push(`No findings.`)
382
+ } else {
383
+ for (const finding of findings) {
384
+ lines.push(
385
+ `${finding.severity.toUpperCase()} ${finding.kind} ` +
386
+ `${path.relative(result.packageDir, finding.file)}:${finding.line}`
387
+ )
388
+ lines.push(` ${finding.title}`)
389
+ lines.push(` ${finding.message}`)
390
+ }
391
+ }
392
+
393
+ if (!changedLines) {
394
+ lines.push(``)
395
+ lines.push(`Recursive Methods:`)
396
+ for (const report of result.reports.recursiveMethods) {
397
+ const cycles =
398
+ report.callees.length === 0
399
+ ? `no internal calls`
400
+ : report.callees.join(`, `)
401
+ lines.push(
402
+ ` ${report.name} (${path.relative(result.packageDir, report.file)}:${report.line}) -> ${cycles}`
403
+ )
404
+ }
405
+
406
+ lines.push(``)
407
+ lines.push(`Shared Field Candidates:`)
408
+ for (const report of result.reports.sharedFieldReport) {
409
+ const flag = report.risky ? `!` : `-`
410
+ lines.push(
411
+ ` ${flag} ${report.field}: writers=${report.writerMethods.join(`, `) || `none`} ` +
412
+ `readers=${report.readerMethods.join(`, `) || `none`}`
413
+ )
414
+ }
415
+
416
+ lines.push(``)
417
+ lines.push(`Ignored Action Sites:`)
418
+ for (const report of result.reports.ignoredActionReport) {
419
+ lines.push(
420
+ ` ${report.className}.${report.methodName} lines ${report.lines.join(`, `)}`
421
+ )
422
+ }
423
+
424
+ lines.push(``)
425
+ lines.push(`Protocol Literal Sites:`)
426
+ if (result.reports.protocolLiteralReport.length === 0) {
427
+ lines.push(` none`)
428
+ } else {
429
+ for (const report of result.reports.protocolLiteralReport) {
430
+ lines.push(
431
+ ` ${report.kind} ${path.relative(result.packageDir, report.file)}:${report.line} ` +
432
+ `${report.literal} -> ${report.canonical} (${report.context})`
433
+ )
434
+ }
435
+ }
436
+ }
437
+
438
+ return lines.join(`\n`)
439
+ }
440
+
441
+ function analyzeMethod(sourceFile, methodNames, fieldNames, methodNode) {
442
+ const name = formatMemberName(methodNode.name)
443
+ const summary = {
444
+ name,
445
+ file: sourceFile.fileName,
446
+ line: getLine(sourceFile, methodNode.name),
447
+ async: methodNode.modifiers?.some(
448
+ (modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword
449
+ )
450
+ ? true
451
+ : false,
452
+ public: !name.startsWith(`#`) && name !== `constructor`,
453
+ calls: [],
454
+ fieldReads: new Map(),
455
+ fieldWrites: new Map(),
456
+ awaits: [],
457
+ }
458
+
459
+ walk(methodNode.body, (node) => {
460
+ if (ts.isAwaitExpression(node)) {
461
+ summary.awaits.push(getLine(sourceFile, node))
462
+ return
463
+ }
464
+
465
+ if (ts.isCallExpression(node)) {
466
+ const callee = getThisMemberName(node.expression)
467
+ if (callee && methodNames.has(callee)) {
468
+ summary.calls.push({
469
+ callee,
470
+ line: getLine(sourceFile, node),
471
+ })
472
+ }
473
+ return
474
+ }
475
+
476
+ if (!ts.isPropertyAccessExpression(node)) return
477
+
478
+ const member = getThisMemberName(node)
479
+ if (!member || methodNames.has(member) || !fieldNames.has(member)) return
480
+
481
+ if (ts.isCallExpression(node.parent) && node.parent.expression === node) {
482
+ return
483
+ }
484
+
485
+ const line = getLine(sourceFile, node)
486
+ if (isWritePosition(node)) {
487
+ pushMapArray(summary.fieldWrites, member, line)
488
+ } else {
489
+ pushMapArray(summary.fieldReads, member, line)
490
+ }
491
+ })
492
+
493
+ return summary
494
+ }
495
+
496
+ function buildClassInfo(sourceFile, classDecl) {
497
+ const fieldNames = new Set()
498
+ const methodNames = new Set()
499
+ const methods = new Map()
500
+
501
+ for (const member of classDecl.members) {
502
+ if (
503
+ ts.isPropertyDeclaration(member) &&
504
+ member.name &&
505
+ (ts.isIdentifier(member.name) || ts.isPrivateIdentifier(member.name))
506
+ ) {
507
+ fieldNames.add(formatMemberName(member.name))
508
+ continue
509
+ }
510
+
511
+ if (
512
+ ts.isGetAccessorDeclaration(member) &&
513
+ member.name &&
514
+ (ts.isIdentifier(member.name) || ts.isPrivateIdentifier(member.name))
515
+ ) {
516
+ fieldNames.add(formatMemberName(member.name))
517
+ continue
518
+ }
519
+
520
+ if (
521
+ ts.isMethodDeclaration(member) &&
522
+ member.name &&
523
+ (ts.isIdentifier(member.name) || ts.isPrivateIdentifier(member.name))
524
+ ) {
525
+ methodNames.add(formatMemberName(member.name))
526
+ continue
527
+ }
528
+ }
529
+
530
+ for (const member of classDecl.members) {
531
+ if (!ts.isMethodDeclaration(member) || !member.body || !member.name)
532
+ continue
533
+ methods.set(
534
+ formatMemberName(member.name),
535
+ analyzeMethod(sourceFile, methodNames, fieldNames, member)
536
+ )
537
+ }
538
+
539
+ return {
540
+ sourceFile,
541
+ fieldNames,
542
+ methodNames,
543
+ methods,
544
+ }
545
+ }
546
+
547
+ function buildRecursiveMethodReport(classInfo) {
548
+ const graph = new Map()
549
+ for (const [name, method] of classInfo.methods) {
550
+ graph.set(name, [
551
+ ...new Set(
552
+ method.calls
553
+ .map((call) => call.callee)
554
+ .filter((callee) => callee !== name)
555
+ ),
556
+ ])
557
+ }
558
+
559
+ const recursiveSet = new Set()
560
+ for (const component of stronglyConnectedComponents(graph)) {
561
+ if (component.length > 1) {
562
+ component.forEach((name) => recursiveSet.add(name))
563
+ continue
564
+ }
565
+
566
+ const [single] = component
567
+ const method = classInfo.methods.get(single)
568
+ if (method?.calls.some((call) => call.callee === single)) {
569
+ recursiveSet.add(single)
570
+ }
571
+ }
572
+
573
+ return [...classInfo.methods.values()]
574
+ .filter((method) => recursiveSet.has(method.name))
575
+ .map((method) => ({
576
+ name: method.name,
577
+ file: method.file,
578
+ line: method.line,
579
+ callees: [...new Set(method.calls.map((call) => call.callee))].sort(),
580
+ }))
581
+ .sort(compareReports)
582
+ }
583
+
584
+ function buildSharedFieldReport(classInfo) {
585
+ const reports = []
586
+
587
+ for (const field of [...classInfo.fieldNames].sort()) {
588
+ if (SHARED_FIELD_IGNORE.has(field)) continue
589
+ if (!isCandidateEphemeralField(field)) continue
590
+
591
+ const writers = []
592
+ const readers = []
593
+ const reasons = []
594
+
595
+ for (const method of classInfo.methods.values()) {
596
+ const writeLines = method.fieldWrites.get(field) ?? []
597
+ const readLines = method.fieldReads.get(field) ?? []
598
+
599
+ if (writeLines.length > 0) {
600
+ const hasAsyncBoundary = writeLines.some((line) =>
601
+ hasAsyncBoundaryAfterLine(method, classInfo.methods, line)
602
+ )
603
+
604
+ writers.push({
605
+ method: method.name,
606
+ lines: writeLines,
607
+ hasAsyncBoundary,
608
+ })
609
+
610
+ if (hasAsyncBoundary) {
611
+ reasons.push(
612
+ `${field} is written in ${method.name} before a later await/async internal call`
613
+ )
614
+ }
615
+ }
616
+
617
+ if (readLines.length > 0) {
618
+ readers.push({
619
+ method: method.name,
620
+ lines: readLines,
621
+ })
622
+ }
623
+ }
624
+
625
+ if (writers.length === 0 && readers.length === 0) continue
626
+
627
+ const writerMethods = writers.map((writer) => writer.method)
628
+ const readerMethods = readers.map((reader) => reader.method)
629
+ const writerLines = writers.flatMap((writer) => writer.lines)
630
+ const readerLines = readers.flatMap((reader) => reader.lines)
631
+ const crossMethodUse = new Set(writerMethods.concat(readerMethods)).size > 1
632
+ const hasRiskyWriter = writers.some((writer) => writer.hasAsyncBoundary)
633
+ const constructUrlConsumes = readers.some(
634
+ (reader) => reader.method === `#constructUrl`
635
+ )
636
+ const publicMethodTouches = readers
637
+ .concat(writers)
638
+ .some((entry) => !entry.method.startsWith(`#`))
639
+ const highRiskField = /(?:Buster|Retry)/.test(field)
640
+
641
+ if (constructUrlConsumes) {
642
+ reasons.push(
643
+ `${field} is consumed by #constructUrl, which multiple paths call`
644
+ )
645
+ }
646
+ if (publicMethodTouches) {
647
+ reasons.push(`${field} is reachable from a public API surface`)
648
+ }
649
+
650
+ reports.push({
651
+ field,
652
+ risky:
653
+ crossMethodUse &&
654
+ hasRiskyWriter &&
655
+ (constructUrlConsumes || highRiskField),
656
+ primaryLine: writerLines[0] ?? readerLines[0],
657
+ writerMethods,
658
+ readerMethods,
659
+ writerLines,
660
+ readerLines,
661
+ reasons: [...new Set(reasons)].sort(),
662
+ })
663
+ }
664
+
665
+ return reports.sort(compareReports)
666
+ }
667
+
668
+ function stronglyConnectedComponents(graph) {
669
+ let index = 0
670
+ const stack = []
671
+ const indices = new Map()
672
+ const lowLinks = new Map()
673
+ const onStack = new Set()
674
+ const components = []
675
+
676
+ const visit = (node) => {
677
+ indices.set(node, index)
678
+ lowLinks.set(node, index)
679
+ index += 1
680
+ stack.push(node)
681
+ onStack.add(node)
682
+
683
+ for (const neighbor of graph.get(node) ?? []) {
684
+ if (!indices.has(neighbor)) {
685
+ visit(neighbor)
686
+ lowLinks.set(node, Math.min(lowLinks.get(node), lowLinks.get(neighbor)))
687
+ } else if (onStack.has(neighbor)) {
688
+ lowLinks.set(node, Math.min(lowLinks.get(node), indices.get(neighbor)))
689
+ }
690
+ }
691
+
692
+ if (lowLinks.get(node) !== indices.get(node)) return
693
+
694
+ const component = []
695
+ while (stack.length > 0) {
696
+ const current = stack.pop()
697
+ onStack.delete(current)
698
+ component.push(current)
699
+ if (current === node) break
700
+ }
701
+ components.push(component.sort())
702
+ }
703
+
704
+ for (const node of graph.keys()) {
705
+ if (!indices.has(node)) visit(node)
706
+ }
707
+
708
+ return components
709
+ }
710
+
711
+ function parseChangedLines(diffOutput) {
712
+ const changedLines = new Map()
713
+ let currentFile
714
+
715
+ for (const line of diffOutput.split(`\n`)) {
716
+ if (line.startsWith(`+++ b/`)) {
717
+ currentFile = path.join(GIT_ROOT, line.slice(6))
718
+ continue
719
+ }
720
+
721
+ if (!line.startsWith(`@@`) || !currentFile) continue
722
+
723
+ const match = /@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line)
724
+ if (!match) continue
725
+
726
+ const start = Number(match[1])
727
+ const count = Number(match[2] ?? `1`)
728
+ const lines = changedLines.get(currentFile) ?? new Set()
729
+ const end = count === 0 ? start : start + count - 1
730
+
731
+ for (let lineNumber = start; lineNumber <= end; lineNumber += 1) {
732
+ lines.add(lineNumber)
733
+ }
734
+ changedLines.set(currentFile, lines)
735
+ }
736
+
737
+ return changedLines
738
+ }
739
+
740
+ function listAnalysisFiles(packageDir) {
741
+ const filePaths = []
742
+
743
+ for (const relativeDir of ANALYSIS_DIRS) {
744
+ const absoluteDir = path.join(packageDir, relativeDir)
745
+ if (!fs.existsSync(absoluteDir)) continue
746
+ walkDirectory(absoluteDir, filePaths)
747
+ }
748
+
749
+ return filePaths.sort()
750
+ }
751
+
752
+ function walkDirectory(directory, filePaths) {
753
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
754
+ if (entry.name.startsWith(`.`)) continue
755
+ if (ANALYSIS_EXCLUDED_DIRS.has(entry.name)) continue
756
+
757
+ const absolutePath = path.join(directory, entry.name)
758
+
759
+ if (entry.isDirectory()) {
760
+ walkDirectory(absolutePath, filePaths)
761
+ continue
762
+ }
763
+
764
+ if (!ANALYSIS_EXTENSIONS.has(path.extname(entry.name))) continue
765
+ if (absolutePath === path.join(PACKAGE_DIR, `src`, `constants.ts`)) continue
766
+ filePaths.push(absolutePath)
767
+ }
768
+ }
769
+
770
+ function lineIsChanged(changedLines, file, line) {
771
+ return changedLines.get(file)?.has(line) ?? false
772
+ }
773
+
774
+ function readSourceFile(filePath) {
775
+ const text = fs.readFileSync(filePath, `utf8`)
776
+ return ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true)
777
+ }
778
+
779
+ function getProtocolLiteralCandidate(sourceFile, node) {
780
+ if (ts.isCallExpression(node)) {
781
+ return getProtocolLiteralCandidateFromCall(sourceFile, node)
782
+ }
783
+
784
+ if (isProtocolHeaderProperty(node)) {
785
+ return getProtocolLiteralCandidateFromHeaderProperty(sourceFile, node)
786
+ }
787
+
788
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
789
+ return getProtocolLiteralCandidateFromLiteral(sourceFile, node)
790
+ }
791
+
792
+ return null
793
+ }
794
+
795
+ function getProtocolLiteralCandidateFromCall(sourceFile, callExpression) {
796
+ if (!ts.isPropertyAccessExpression(callExpression.expression)) return null
797
+ if (!PROTOCOL_LITERAL_METHODS.has(callExpression.expression.name.text)) {
798
+ return null
799
+ }
800
+
801
+ const [firstArg] = callExpression.arguments
802
+ const literal = getStringLiteralValue(firstArg)
803
+ if (!literal) return null
804
+
805
+ const receiver = callExpression.expression.expression
806
+ const receiverContext = getProtocolReceiverContext(receiver)
807
+ if (!receiverContext) return null
808
+
809
+ return createProtocolLiteralCandidate(
810
+ sourceFile,
811
+ firstArg,
812
+ literal,
813
+ `${receiverContext}.${callExpression.expression.name.text}`
814
+ )
815
+ }
816
+
817
+ function getProtocolLiteralCandidateFromHeaderProperty(
818
+ sourceFile,
819
+ propertyNode
820
+ ) {
821
+ const literal = getPropertyNameValue(propertyNode.name)
822
+ if (!literal) return null
823
+
824
+ return createProtocolLiteralCandidate(
825
+ sourceFile,
826
+ propertyNode.name,
827
+ literal,
828
+ `headers object property`
829
+ )
830
+ }
831
+
832
+ function getProtocolLiteralCandidateFromLiteral(sourceFile, node) {
833
+ const literal = node.text
834
+ if (!PROTOCOL_LITERAL_CANONICAL_VALUES.includes(literal)) return null
835
+
836
+ const parent = node.parent
837
+ if (!ts.isArrayLiteralExpression(parent)) return null
838
+ if (!isProtocolLiteralArray(parent)) return null
839
+
840
+ return createProtocolLiteralCandidate(
841
+ sourceFile,
842
+ node,
843
+ literal,
844
+ `protocol literal array`
845
+ )
846
+ }
847
+
848
+ function createProtocolLiteralCandidate(sourceFile, node, literal, context) {
849
+ const canonical = PROTOCOL_LITERAL_BY_NORMALIZED.get(
850
+ normalizeLiteral(literal)
851
+ )
852
+ if (!canonical) return null
853
+
854
+ return {
855
+ file: sourceFile.fileName,
856
+ line: getLine(sourceFile, node),
857
+ literal,
858
+ canonical,
859
+ context,
860
+ }
861
+ }
862
+
863
+ function getProtocolReceiverContext(receiver) {
864
+ if (ts.isPropertyAccessExpression(receiver)) {
865
+ if (receiver.name.text === `searchParams`) return `searchParams`
866
+ if (receiver.name.text === `headers`) return `headers`
867
+ }
868
+
869
+ if (ts.isIdentifier(receiver) && receiver.text === `headers`) {
870
+ return `headers`
871
+ }
872
+
873
+ return null
874
+ }
875
+
876
+ function isHeadersObjectLiteral(node) {
877
+ const parent = node.parent
878
+
879
+ if (
880
+ ts.isPropertyAssignment(parent) &&
881
+ getPropertyNameValue(parent.name) === `headers`
882
+ ) {
883
+ return true
884
+ }
885
+
886
+ if (
887
+ ts.isNewExpression(parent) &&
888
+ ts.isIdentifier(parent.expression) &&
889
+ parent.expression.text === `Headers`
890
+ ) {
891
+ return true
892
+ }
893
+
894
+ return false
895
+ }
896
+
897
+ function isProtocolHeaderProperty(node) {
898
+ if (!ts.isPropertyAssignment(node) || !node.name) return false
899
+ if (!ts.isObjectLiteralExpression(node.parent)) return false
900
+ return isHeadersObjectLiteral(node.parent)
901
+ }
902
+
903
+ function isProtocolLiteralArray(node) {
904
+ const parent = node.parent
905
+ if (!ts.isVariableDeclaration(parent) || !ts.isIdentifier(parent.name)) {
906
+ return false
907
+ }
908
+
909
+ return /(?:Header|Param)s$/.test(parent.name.text)
910
+ }
911
+
912
+ function walk(node, visit) {
913
+ visit(node)
914
+ ts.forEachChild(node, (child) => walk(child, visit))
915
+ }
916
+
917
+ function getThisMemberName(node) {
918
+ if (!ts.isPropertyAccessExpression(node)) return null
919
+ if (node.expression.kind !== ts.SyntaxKind.ThisKeyword) return null
920
+ return formatMemberName(node.name)
921
+ }
922
+
923
+ function isWritePosition(node) {
924
+ const parent = node.parent
925
+ if (!parent) return false
926
+
927
+ if (
928
+ ts.isBinaryExpression(parent) &&
929
+ parent.left === node &&
930
+ parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
931
+ parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment
932
+ ) {
933
+ return true
934
+ }
935
+
936
+ if (
937
+ (ts.isPrefixUnaryExpression(parent) ||
938
+ ts.isPostfixUnaryExpression(parent)) &&
939
+ (parent.operator === ts.SyntaxKind.PlusPlusToken ||
940
+ parent.operator === ts.SyntaxKind.MinusMinusToken)
941
+ ) {
942
+ return true
943
+ }
944
+
945
+ return false
946
+ }
947
+
948
+ function getLine(sourceFile, node) {
949
+ return (
950
+ sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1
951
+ )
952
+ }
953
+
954
+ function formatMemberName(nameNode) {
955
+ if (ts.isPrivateIdentifier(nameNode)) {
956
+ return nameNode.text.startsWith(`#`) ? nameNode.text : `#${nameNode.text}`
957
+ }
958
+ if (
959
+ ts.isIdentifier(nameNode) ||
960
+ ts.isStringLiteral(nameNode) ||
961
+ ts.isNumericLiteral(nameNode)
962
+ ) {
963
+ return `${nameNode.text}`
964
+ }
965
+ return nameNode.getText()
966
+ }
967
+
968
+ function getPropertyNameValue(nameNode) {
969
+ if (
970
+ ts.isIdentifier(nameNode) ||
971
+ ts.isStringLiteral(nameNode) ||
972
+ ts.isNoSubstitutionTemplateLiteral(nameNode)
973
+ ) {
974
+ return nameNode.text
975
+ }
976
+
977
+ return null
978
+ }
979
+
980
+ function getStringLiteralValue(node) {
981
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
982
+ return node.text
983
+ }
984
+
985
+ return null
986
+ }
987
+
988
+ function hasAsyncBoundaryAfterLine(method, methods, line) {
989
+ if (method.awaits.some((awaitLine) => awaitLine > line)) return true
990
+
991
+ return method.calls.some((call) => {
992
+ if (call.line <= line) return false
993
+ return methods.get(call.callee)?.async ?? false
994
+ })
995
+ }
996
+
997
+ function isCandidateEphemeralField(field) {
998
+ return /(?:Buster|Retry|Count|Counter|Recent|AbortController|Promise|Refresh|Duplicate)/.test(
999
+ field
1000
+ )
1001
+ }
1002
+
1003
+ function pushMapArray(map, key, value) {
1004
+ const values = map.get(key)
1005
+ if (values) {
1006
+ values.push(value)
1007
+ return
1008
+ }
1009
+ map.set(key, [value])
1010
+ }
1011
+
1012
+ function uniqueLocations(locations) {
1013
+ const seen = new Set()
1014
+ const result = []
1015
+
1016
+ for (const location of locations) {
1017
+ const key = `${location.file}:${location.line}:${location.label ?? ``}`
1018
+ if (seen.has(key)) continue
1019
+ seen.add(key)
1020
+ result.push(location)
1021
+ }
1022
+
1023
+ return result
1024
+ }
1025
+
1026
+ function getObjectLiteralPropertyNode(objectLiteral, propertyName) {
1027
+ return objectLiteral.properties.find((property) => {
1028
+ if (!ts.isPropertyAssignment(property) || !property.name) return false
1029
+ return formatMemberName(property.name) === propertyName
1030
+ })
1031
+ }
1032
+
1033
+ function getObjectLiteralPropertyValue(objectLiteral, propertyName) {
1034
+ const property = getObjectLiteralPropertyNode(objectLiteral, propertyName)
1035
+ if (!property || !ts.isPropertyAssignment(property)) return undefined
1036
+ if (ts.isStringLiteral(property.initializer)) return property.initializer.text
1037
+ return undefined
1038
+ }
1039
+
1040
+ function compareFindings(left, right) {
1041
+ const fileCompare = left.file.localeCompare(right.file)
1042
+ if (fileCompare !== 0) return fileCompare
1043
+ return left.line - right.line
1044
+ }
1045
+
1046
+ function compareReports(left, right) {
1047
+ const leftLine = left.line ?? left.primaryLine ?? 0
1048
+ const rightLine = right.line ?? right.primaryLine ?? 0
1049
+ if (leftLine !== rightLine) return leftLine - rightLine
1050
+ return (left.file ?? ``).localeCompare(right.file ?? ``)
1051
+ }
1052
+
1053
+ function resolveGitRoot() {
1054
+ try {
1055
+ return execFileSync(`git`, [`rev-parse`, `--show-toplevel`], {
1056
+ cwd: PACKAGE_DIR,
1057
+ encoding: `utf8`,
1058
+ stdio: [`ignore`, `pipe`, `ignore`],
1059
+ }).trim()
1060
+ } catch {
1061
+ return PACKAGE_DIR
1062
+ }
1063
+ }
1064
+
1065
+ function normalizeLiteral(value) {
1066
+ return value.toLowerCase().replace(/[^a-z0-9]/g, ``)
1067
+ }