@pikku/inspector 0.12.20 → 0.12.22
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/CHANGELOG.md +31 -0
- package/dist/add/add-auth.js +15 -0
- package/dist/add/add-cli.js +8 -22
- package/dist/add/add-functions.js +16 -8
- package/dist/add/add-workflow.js +16 -2
- package/dist/error-codes.d.ts +12 -0
- package/dist/index.d.ts +1 -0
- package/dist/inspector.js +5 -1
- package/dist/types.d.ts +13 -1
- package/dist/utils/extract-node-value.js +19 -2
- package/dist/utils/workflow/dsl/extract-dsl-workflow.js +15 -0
- package/package.json +3 -2
- package/src/add/add-auth.test.ts +86 -0
- package/src/add/add-auth.ts +19 -0
- package/src/add/add-cli-renderers.test.ts +74 -0
- package/src/add/add-cli.ts +9 -25
- package/src/add/add-functions.test.ts +13 -0
- package/src/add/add-functions.ts +14 -10
- package/src/add/add-workflow-fanout.test.ts +106 -0
- package/src/add/add-workflow.test.ts +3 -0
- package/src/add/add-workflow.ts +16 -2
- package/src/add/pii-check.test.ts +4 -0
- package/src/add/wire-name-literal.test.ts +3 -0
- package/src/error-codes.ts +14 -0
- package/src/index.ts +1 -0
- package/src/inspector.ts +8 -1
- package/src/types.ts +13 -1
- package/src/utils/extract-node-value.test.ts +49 -1
- package/src/utils/extract-node-value.ts +19 -2
- package/src/utils/filter-inspector-state.test.ts +1 -0
- package/src/utils/filter-utils.test.ts +1 -0
- package/src/utils/resolve-versions.test.ts +1 -0
- package/src/utils/workflow/dsl/extract-dsl-workflow.ts +16 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -39,6 +39,9 @@ describe('addFunctions duplicate name handling', () => {
|
|
|
39
39
|
info: () => {},
|
|
40
40
|
warn: () => {},
|
|
41
41
|
error: () => {},
|
|
42
|
+
diagnostic: ({ code, message }) => {
|
|
43
|
+
criticals.push({ code, message })
|
|
44
|
+
},
|
|
42
45
|
critical: (code: ErrorCode, message: string) => {
|
|
43
46
|
criticals.push({ code, message })
|
|
44
47
|
},
|
|
@@ -91,6 +94,9 @@ describe('addFunctions duplicate name handling', () => {
|
|
|
91
94
|
info: () => {},
|
|
92
95
|
warn: () => {},
|
|
93
96
|
error: () => {},
|
|
97
|
+
diagnostic: ({ code, message }) => {
|
|
98
|
+
criticals.push({ code, message })
|
|
99
|
+
},
|
|
94
100
|
critical: (code: ErrorCode, message: string) => {
|
|
95
101
|
criticals.push({ code, message })
|
|
96
102
|
},
|
|
@@ -142,6 +148,7 @@ describe('addFunctions duplicate name handling', () => {
|
|
|
142
148
|
info: () => {},
|
|
143
149
|
warn: () => {},
|
|
144
150
|
error: () => {},
|
|
151
|
+
diagnostic: () => {},
|
|
145
152
|
critical: () => {},
|
|
146
153
|
hasCriticalErrors: () => false,
|
|
147
154
|
}
|
|
@@ -204,6 +211,9 @@ describe('addFunctions duplicate name handling', () => {
|
|
|
204
211
|
info: () => {},
|
|
205
212
|
warn: () => {},
|
|
206
213
|
error: () => {},
|
|
214
|
+
diagnostic: ({ code, message }) => {
|
|
215
|
+
criticals.push({ code, message })
|
|
216
|
+
},
|
|
207
217
|
critical: (code: ErrorCode, message: string) => {
|
|
208
218
|
criticals.push({ code, message })
|
|
209
219
|
},
|
|
@@ -250,6 +260,7 @@ describe('addFunctions implementationHash', () => {
|
|
|
250
260
|
info: () => {},
|
|
251
261
|
warn: () => {},
|
|
252
262
|
error: () => {},
|
|
263
|
+
diagnostic: () => {},
|
|
253
264
|
critical: () => {},
|
|
254
265
|
hasCriticalErrors: () => false,
|
|
255
266
|
}
|
|
@@ -296,6 +307,7 @@ describe('addFunctions implementationHash', () => {
|
|
|
296
307
|
info: () => {},
|
|
297
308
|
warn: () => {},
|
|
298
309
|
error: () => {},
|
|
310
|
+
diagnostic: () => {},
|
|
299
311
|
critical: () => {},
|
|
300
312
|
hasCriticalErrors: () => false,
|
|
301
313
|
}
|
|
@@ -349,6 +361,7 @@ describe('pikkuChannelConnectionFunc generic mapping', () => {
|
|
|
349
361
|
info: () => {},
|
|
350
362
|
warn: () => {},
|
|
351
363
|
error: () => {},
|
|
364
|
+
diagnostic: () => {},
|
|
352
365
|
critical: () => {},
|
|
353
366
|
hasCriticalErrors: () => false,
|
|
354
367
|
}
|
package/src/add/add-functions.ts
CHANGED
|
@@ -931,23 +931,27 @@ export const addFunctions: AddWiring = (
|
|
|
931
931
|
.map((f) => f.path)
|
|
932
932
|
|
|
933
933
|
if (secretPaths.length > 0) {
|
|
934
|
-
logger.
|
|
935
|
-
|
|
936
|
-
|
|
934
|
+
logger.diagnostic({
|
|
935
|
+
severity: 'error',
|
|
936
|
+
code: ErrorCode.PII_IN_OUTPUT,
|
|
937
|
+
message:
|
|
938
|
+
`Function '${name}' exposes secret-classified field(s) in its return type: ` +
|
|
937
939
|
secretPaths.map((p) => `'${p}'`).join(', ') +
|
|
938
940
|
`.\n Secret fields must never appear in function output. ` +
|
|
939
|
-
`Strip these fields before returning or change the column classification
|
|
940
|
-
)
|
|
941
|
+
`Strip these fields before returning or change the column classification.`,
|
|
942
|
+
})
|
|
941
943
|
}
|
|
942
944
|
|
|
943
945
|
if (sessionless && privatePaths.length > 0) {
|
|
944
|
-
logger.
|
|
945
|
-
|
|
946
|
-
|
|
946
|
+
logger.diagnostic({
|
|
947
|
+
severity: 'error',
|
|
948
|
+
code: ErrorCode.PII_IN_OUTPUT,
|
|
949
|
+
message:
|
|
950
|
+
`Sessionless function '${name}' exposes private-classified field(s) in its return type: ` +
|
|
947
951
|
privatePaths.map((p) => `'${p}'`).join(', ') +
|
|
948
952
|
`.\n Private fields are only safe to return from authenticated (sessioned) functions. ` +
|
|
949
|
-
`Either require a session (use pikkuFunc) or mark the column @public if it is safe to expose publicly
|
|
950
|
-
)
|
|
953
|
+
`Either require a session (use pikkuFunc) or mark the column @public if it is safe to expose publicly.`,
|
|
954
|
+
})
|
|
951
955
|
}
|
|
952
956
|
}
|
|
953
957
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { strict as assert } from 'assert'
|
|
2
|
+
import { describe, test } from 'node:test'
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { inspect } from '../inspector.js'
|
|
7
|
+
import type { InspectorLogger } from '../types.js'
|
|
8
|
+
|
|
9
|
+
function makeLogger(
|
|
10
|
+
criticals: Array<{ code: string; message: string }>
|
|
11
|
+
): InspectorLogger {
|
|
12
|
+
return {
|
|
13
|
+
debug: () => {},
|
|
14
|
+
info: () => {},
|
|
15
|
+
warn: () => {},
|
|
16
|
+
error: () => {},
|
|
17
|
+
diagnostic: ({ code, message }) => {
|
|
18
|
+
criticals.push({ code, message })
|
|
19
|
+
},
|
|
20
|
+
critical: (code: any, message: string) => {
|
|
21
|
+
criticals.push({ code, message })
|
|
22
|
+
},
|
|
23
|
+
hasCriticalErrors: () => criticals.length > 0,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const STEP_FILE = [
|
|
28
|
+
"import { pikkuSessionlessFunc } from '@pikku/core'",
|
|
29
|
+
'export const processEventLeadsStep = pikkuSessionlessFunc({',
|
|
30
|
+
' func: async ({ logger }) => ({ persistedCount: 1 }),',
|
|
31
|
+
'})',
|
|
32
|
+
].join('\n')
|
|
33
|
+
|
|
34
|
+
describe('addWorkflow — Promise.all fanout RPC detection', () => {
|
|
35
|
+
test('registers fanout RPC when captured with `const x = await Promise.all(map(...))`', async () => {
|
|
36
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-fanout-const-'))
|
|
37
|
+
const wfFile = join(rootDir, 'leads.workflow.ts')
|
|
38
|
+
const stepFile = join(rootDir, 'leads.steps.ts')
|
|
39
|
+
|
|
40
|
+
await writeFile(stepFile, STEP_FILE)
|
|
41
|
+
await writeFile(
|
|
42
|
+
wfFile,
|
|
43
|
+
[
|
|
44
|
+
"import { pikkuWorkflowFunc } from '@pikku/core/workflow'",
|
|
45
|
+
'export const extractLeadsWorkflow = pikkuWorkflowFunc(async (_, data, { workflow }) => {',
|
|
46
|
+
' const events = [{ id: "a", name: "x" }]',
|
|
47
|
+
' const processed = await Promise.all(',
|
|
48
|
+
' events.map((event) =>',
|
|
49
|
+
" workflow.do(`Enrich event ${event.id ?? event.name}`, 'processEventLeadsStep', { event })",
|
|
50
|
+
' )',
|
|
51
|
+
' )',
|
|
52
|
+
' return { count: processed.length }',
|
|
53
|
+
'})',
|
|
54
|
+
].join('\n')
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
const criticals: Array<{ code: string; message: string }> = []
|
|
58
|
+
try {
|
|
59
|
+
const state = await inspect(makeLogger(criticals), [stepFile, wfFile], {
|
|
60
|
+
rootDir,
|
|
61
|
+
})
|
|
62
|
+
assert.ok(
|
|
63
|
+
state.rpc.invokedFunctions.has('processEventLeadsStep'),
|
|
64
|
+
'processEventLeadsStep should be registered when fanout is captured with const'
|
|
65
|
+
)
|
|
66
|
+
} finally {
|
|
67
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('registers fanout RPC with string-concatenation (`+`) step name, same as template literal', async () => {
|
|
72
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-fanout-concat-'))
|
|
73
|
+
const wfFile = join(rootDir, 'leads.workflow.ts')
|
|
74
|
+
const stepFile = join(rootDir, 'leads.steps.ts')
|
|
75
|
+
|
|
76
|
+
await writeFile(stepFile, STEP_FILE)
|
|
77
|
+
await writeFile(
|
|
78
|
+
wfFile,
|
|
79
|
+
[
|
|
80
|
+
"import { pikkuWorkflowFunc } from '@pikku/core/workflow'",
|
|
81
|
+
'export const extractLeadsWorkflow = pikkuWorkflowFunc(async (_, data, { workflow }) => {',
|
|
82
|
+
' const events = [{ id: "a", name: "x" }]',
|
|
83
|
+
' await Promise.all(',
|
|
84
|
+
' events.map((event) =>',
|
|
85
|
+
" workflow.do('Enrich event ' + (event.id ?? event.name), 'processEventLeadsStep', { event })",
|
|
86
|
+
' )',
|
|
87
|
+
' )',
|
|
88
|
+
' return { ok: true }',
|
|
89
|
+
'})',
|
|
90
|
+
].join('\n')
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
const criticals: Array<{ code: string; message: string }> = []
|
|
94
|
+
try {
|
|
95
|
+
const state = await inspect(makeLogger(criticals), [stepFile, wfFile], {
|
|
96
|
+
rootDir,
|
|
97
|
+
})
|
|
98
|
+
assert.ok(
|
|
99
|
+
state.rpc.invokedFunctions.has('processEventLeadsStep'),
|
|
100
|
+
'processEventLeadsStep should be registered even when the step name uses `+` concatenation with a non-static operand'
|
|
101
|
+
)
|
|
102
|
+
} finally {
|
|
103
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
})
|
package/src/add/add-workflow.ts
CHANGED
|
@@ -16,6 +16,20 @@ import {
|
|
|
16
16
|
getPropertyValue,
|
|
17
17
|
} from '../utils/get-property-value.js'
|
|
18
18
|
import { extractDSLWorkflow } from '../utils/workflow/dsl/extract-dsl-workflow.js'
|
|
19
|
+
import { getSourceText } from '../utils/workflow/dsl/patterns.js'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Extract a workflow step's display name without letting a non-static name
|
|
23
|
+
* (e.g. a function call) abort the scan. The step name is cosmetic, so a
|
|
24
|
+
* resolution failure must never prevent the RPC from being registered.
|
|
25
|
+
*/
|
|
26
|
+
function extractStepName(node: ts.Node, checker: ts.TypeChecker): string {
|
|
27
|
+
try {
|
|
28
|
+
return extractStringLiteral(node, checker)
|
|
29
|
+
} catch {
|
|
30
|
+
return getSourceText(node)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
19
33
|
|
|
20
34
|
/**
|
|
21
35
|
* Recursively check if any step has inline type (non-serializable)
|
|
@@ -99,7 +113,7 @@ function getWorkflowInvocations(
|
|
|
99
113
|
const optionsArg =
|
|
100
114
|
args.length >= 4 ? args[args.length - 1] : undefined
|
|
101
115
|
|
|
102
|
-
const stepName =
|
|
116
|
+
const stepName = extractStepName(stepNameArg, checker)
|
|
103
117
|
const description =
|
|
104
118
|
extractDescription(optionsArg, checker) ?? undefined
|
|
105
119
|
|
|
@@ -126,7 +140,7 @@ function getWorkflowInvocations(
|
|
|
126
140
|
const stepNameArg = args[0]
|
|
127
141
|
const durationArg = args[1]
|
|
128
142
|
|
|
129
|
-
const stepName =
|
|
143
|
+
const stepName = extractStepName(stepNameArg, checker)
|
|
130
144
|
const duration = extractDuration(durationArg, checker)
|
|
131
145
|
|
|
132
146
|
steps.push({
|
|
@@ -10,12 +10,16 @@ import type { InspectorLogger } from '../types.js'
|
|
|
10
10
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
11
11
|
|
|
12
12
|
function makeLogger() {
|
|
13
|
+
// Collects every coded diagnostic regardless of severity. PKU910 is now
|
|
14
|
+
// emitted at 'error' severity (surface, don't block the dev server) so it
|
|
15
|
+
// arrives via `diagnostic`, not `critical`.
|
|
13
16
|
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
14
17
|
const logger: InspectorLogger = {
|
|
15
18
|
debug: () => {},
|
|
16
19
|
info: () => {},
|
|
17
20
|
warn: () => {},
|
|
18
21
|
error: () => {},
|
|
22
|
+
diagnostic: ({ code, message }) => criticals.push({ code, message }),
|
|
19
23
|
critical: (code, message) => criticals.push({ code, message }),
|
|
20
24
|
hasCriticalErrors: () => criticals.length > 0,
|
|
21
25
|
}
|
|
@@ -13,6 +13,9 @@ const makeLogger = (criticals: Array<{ code: ErrorCode; message: string }>) =>
|
|
|
13
13
|
info: () => {},
|
|
14
14
|
warn: () => {},
|
|
15
15
|
error: () => {},
|
|
16
|
+
diagnostic: ({ code, message }) => {
|
|
17
|
+
criticals.push({ code, message })
|
|
18
|
+
},
|
|
16
19
|
critical: (code: ErrorCode, message: string) => {
|
|
17
20
|
criticals.push({ code, message })
|
|
18
21
|
},
|
package/src/error-codes.ts
CHANGED
|
@@ -85,3 +85,17 @@ export enum ErrorCode {
|
|
|
85
85
|
// Data classification errors
|
|
86
86
|
PII_IN_OUTPUT = 'PKU910',
|
|
87
87
|
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Severity of a tracked, coded diagnostic. `critical` always blocks the build;
|
|
91
|
+
* `error`/`warn` only block when the CLI is told to via `--fail-on-error` /
|
|
92
|
+
* `--fail-on-warn` (default: critical only). All severities are still printed.
|
|
93
|
+
*/
|
|
94
|
+
export type DiagnosticSeverity = 'warn' | 'error' | 'critical'
|
|
95
|
+
|
|
96
|
+
/** A coded diagnostic emitted via `logger.diagnostic(...)`. */
|
|
97
|
+
export interface CodedDiagnostic {
|
|
98
|
+
severity: DiagnosticSeverity
|
|
99
|
+
code: ErrorCode
|
|
100
|
+
message: string
|
|
101
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export type { TypesMap } from './types-map.js'
|
|
|
3
3
|
export type * from './types.js'
|
|
4
4
|
export type { FilesAndMethodsErrors } from './utils/get-files-and-methods.js'
|
|
5
5
|
export { ErrorCode } from './error-codes.js'
|
|
6
|
+
export type { DiagnosticSeverity, CodedDiagnostic } from './error-codes.js'
|
|
6
7
|
export { AUTH_HANDLER_FUNC_ID } from './add/add-auth.js'
|
|
7
8
|
export {
|
|
8
9
|
serializeInspectorState,
|
package/src/inspector.ts
CHANGED
|
@@ -256,9 +256,16 @@ export const inspect = async (
|
|
|
256
256
|
const rootDir = options.rootDir || findCommonAncestor(routeFiles)
|
|
257
257
|
|
|
258
258
|
const startSourceFiles = performance.now()
|
|
259
|
+
// node_modules under rootDir (e.g. a locally-installed addon) is a
|
|
260
|
+
// dependency, not project source — scanning it double-counts the addon's
|
|
261
|
+
// own application types (CoreConfig/Services/SingletonServices).
|
|
259
262
|
const sourceFiles = program
|
|
260
263
|
.getSourceFiles()
|
|
261
|
-
.filter(
|
|
264
|
+
.filter(
|
|
265
|
+
(sf) =>
|
|
266
|
+
sf.fileName.startsWith(rootDir) &&
|
|
267
|
+
!sf.fileName.includes('/node_modules/')
|
|
268
|
+
)
|
|
262
269
|
logger.debug(
|
|
263
270
|
`Got source files in ${(performance.now() - startSourceFiles).toFixed(2)}ms`
|
|
264
271
|
)
|
package/src/types.ts
CHANGED
|
@@ -25,7 +25,7 @@ import type {
|
|
|
25
25
|
JSONValue,
|
|
26
26
|
} from '@pikku/core'
|
|
27
27
|
import type { OpenAPISpecInfo } from './utils/serialize-openapi-json.js'
|
|
28
|
-
import type { ErrorCode } from './error-codes.js'
|
|
28
|
+
import type { ErrorCode, CodedDiagnostic } from './error-codes.js'
|
|
29
29
|
import type {
|
|
30
30
|
VersionManifest,
|
|
31
31
|
VersionValidateError,
|
|
@@ -238,6 +238,15 @@ export interface InspectorLogger {
|
|
|
238
238
|
error: (message: string) => void
|
|
239
239
|
warn: (message: string) => void
|
|
240
240
|
debug: (message: string) => void
|
|
241
|
+
/**
|
|
242
|
+
* Emit a tracked, coded diagnostic. It is recorded and printed; `error`/`warn`
|
|
243
|
+
* only block the build when the CLI is run with `--fail-on-error` /
|
|
244
|
+
* `--fail-on-warn` (default: critical only). Use this for issues worth
|
|
245
|
+
* surfacing (e.g. data-classification leaks) that should not stop the dev
|
|
246
|
+
* server from starting.
|
|
247
|
+
*/
|
|
248
|
+
diagnostic: (diagnostic: CodedDiagnostic) => void
|
|
249
|
+
/** Sugar for `diagnostic({ severity: 'critical', code, message })`. */
|
|
241
250
|
critical: (code: ErrorCode, message: string) => void
|
|
242
251
|
hasCriticalErrors: () => boolean
|
|
243
252
|
}
|
|
@@ -318,6 +327,9 @@ export interface AuthDefinition {
|
|
|
318
327
|
* `auth-meta.gen.json` so the console SSO page can show which plugins are
|
|
319
328
|
* enabled. */
|
|
320
329
|
plugins: string[]
|
|
330
|
+
/** Whether `session.cookieCache` is enabled — drives the stateless session
|
|
331
|
+
* middleware split in the auth codegen. Absent/false ⇒ stateful middleware. */
|
|
332
|
+
cookieCache?: boolean
|
|
321
333
|
/**
|
|
322
334
|
* Singleton services the generated auth handler must have available at
|
|
323
335
|
* runtime — the services the `pikkuBetterAuth` factory reaches for (either
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { test, describe } from 'node:test'
|
|
2
2
|
import { strict as assert } from 'node:assert'
|
|
3
3
|
import * as ts from 'typescript'
|
|
4
|
-
import { extractDescription } from './extract-node-value'
|
|
4
|
+
import { extractDescription, extractStringLiteral } from './extract-node-value'
|
|
5
5
|
|
|
6
6
|
const createChecker = (source: string) => {
|
|
7
7
|
const sourceFile = ts.createSourceFile(
|
|
@@ -67,3 +67,51 @@ describe('extractDescription', () => {
|
|
|
67
67
|
assert.equal(extractDescription(sourceFile, checker), null)
|
|
68
68
|
})
|
|
69
69
|
})
|
|
70
|
+
|
|
71
|
+
describe('extractStringLiteral — concatenation/template symmetry', () => {
|
|
72
|
+
const findInitializer = (node: ts.Node): ts.Expression | undefined => {
|
|
73
|
+
if (ts.isVariableDeclaration(node) && node.initializer) {
|
|
74
|
+
return node.initializer
|
|
75
|
+
}
|
|
76
|
+
let result: ts.Expression | undefined
|
|
77
|
+
ts.forEachChild(node, (child) => {
|
|
78
|
+
if (!result) result = findInitializer(child)
|
|
79
|
+
})
|
|
80
|
+
return result
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
test('a `+` operand that cannot be statically resolved becomes a ${...} placeholder', () => {
|
|
84
|
+
const { checker, sourceFile } = createChecker(
|
|
85
|
+
`const x = 'Enrich event ' + (event.id ?? event.name)`
|
|
86
|
+
)
|
|
87
|
+
const init = findInitializer(sourceFile)!
|
|
88
|
+
assert.equal(
|
|
89
|
+
extractStringLiteral(init, checker),
|
|
90
|
+
'Enrich event ${event.id ?? event.name}'
|
|
91
|
+
)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('`+` concatenation and template literal produce the same display string', () => {
|
|
95
|
+
const concat = createChecker(
|
|
96
|
+
`const x = 'Enrich event ' + (event.id ?? event.name)`
|
|
97
|
+
)
|
|
98
|
+
const template = createChecker(
|
|
99
|
+
'const x = `Enrich event ${event.id ?? event.name}`'
|
|
100
|
+
)
|
|
101
|
+
const concatValue = extractStringLiteral(
|
|
102
|
+
findInitializer(concat.sourceFile)!,
|
|
103
|
+
concat.checker
|
|
104
|
+
)
|
|
105
|
+
const templateValue = extractStringLiteral(
|
|
106
|
+
findInitializer(template.sourceFile)!,
|
|
107
|
+
template.checker
|
|
108
|
+
)
|
|
109
|
+
assert.equal(concatValue, templateValue)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('still resolves fully-static concatenation exactly', () => {
|
|
113
|
+
const { checker, sourceFile } = createChecker(`const x = 'a' + 'b' + 'c'`)
|
|
114
|
+
const init = findInitializer(sourceFile)!
|
|
115
|
+
assert.equal(extractStringLiteral(init, checker), 'abc')
|
|
116
|
+
})
|
|
117
|
+
})
|
|
@@ -37,8 +37,8 @@ export function extractStringLiteral(
|
|
|
37
37
|
node.operatorToken.kind === ts.SyntaxKind.PlusToken
|
|
38
38
|
) {
|
|
39
39
|
return (
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
extractConcatOperand(node.left, checker) +
|
|
41
|
+
extractConcatOperand(node.right, checker)
|
|
42
42
|
)
|
|
43
43
|
}
|
|
44
44
|
|
|
@@ -59,6 +59,23 @@ export function extractStringLiteral(
|
|
|
59
59
|
throw new Error('Unable to extract string literal from node')
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Resolve one operand of a `+` string concatenation.
|
|
64
|
+
*
|
|
65
|
+
* An operand that can't be statically resolved (e.g. `a ?? b`) becomes a
|
|
66
|
+
* `${...}` placeholder rather than throwing — mirroring the TemplateExpression
|
|
67
|
+
* branch above, so `'x ' + expr` and `` `x ${expr}` `` produce the same string.
|
|
68
|
+
* This keeps an unresolvable display name from aborting the whole extraction.
|
|
69
|
+
*/
|
|
70
|
+
function extractConcatOperand(node: ts.Node, checker: ts.TypeChecker): string {
|
|
71
|
+
try {
|
|
72
|
+
return extractStringLiteral(node, checker)
|
|
73
|
+
} catch {
|
|
74
|
+
const inner = ts.isParenthesizedExpression(node) ? node.expression : node
|
|
75
|
+
return '${' + inner.getText() + '}'
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
62
79
|
/**
|
|
63
80
|
* Check if node is string-like (string literal or template expression)
|
|
64
81
|
*/
|
|
@@ -386,6 +386,22 @@ function extractVariableDeclaration(
|
|
|
386
386
|
return step
|
|
387
387
|
}
|
|
388
388
|
}
|
|
389
|
+
|
|
390
|
+
// Promise.all fanout/group captured into a variable
|
|
391
|
+
// (const results = await Promise.all(array.map(...)))
|
|
392
|
+
if (isParallelFanout(call) || isParallelGroup(call)) {
|
|
393
|
+
const step = isParallelFanout(call)
|
|
394
|
+
? extractParallelFanout(call, context)
|
|
395
|
+
: extractParallelGroup(call, context)
|
|
396
|
+
if (step) {
|
|
397
|
+
const type = context.checker.getTypeAtLocation(decl)
|
|
398
|
+
context.outputVars.set(varName, { type, node: decl })
|
|
399
|
+
if (isArrayType(type, context.checker)) {
|
|
400
|
+
context.arrayVars.add(varName)
|
|
401
|
+
}
|
|
402
|
+
return step
|
|
403
|
+
}
|
|
404
|
+
}
|
|
389
405
|
}
|
|
390
406
|
|
|
391
407
|
// Check for array.filter(...)
|