@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.
- package/bin/analyze-pr-risks.mjs +81 -0
- package/bin/analyze-shape-stream-risks.mjs +20 -0
- package/bin/intent.mjs +6 -0
- package/bin/lib/shape-stream-static-analysis.mjs +1067 -0
- package/dist/cjs/index.cjs +134 -96
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/index.browser.mjs +4 -4
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.legacy-esm.js +134 -96
- package/dist/index.legacy-esm.js.map +1 -1
- package/dist/index.mjs +134 -96
- package/dist/index.mjs.map +1 -1
- package/package.json +11 -2
- package/skills/electric-debugging/SKILL.md +217 -0
- package/skills/electric-deployment/SKILL.md +196 -0
- package/skills/electric-new-feature/SKILL.md +366 -0
- package/skills/electric-orm/SKILL.md +189 -0
- package/skills/electric-postgres-security/SKILL.md +196 -0
- package/skills/electric-proxy-auth/SKILL.md +269 -0
- package/skills/electric-schema-shapes/SKILL.md +200 -0
- package/skills/electric-shapes/SKILL.md +339 -0
- package/skills/electric-shapes/references/type-parsers.md +64 -0
- package/skills/electric-shapes/references/where-clause.md +64 -0
- package/src/client.ts +90 -33
- package/src/fetch.ts +6 -4
- package/src/shape-stream-state.ts +13 -19
|
@@ -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
|
+
}
|