@davidwells/llrt-analyzer 0.1.0

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/src/cli.js ADDED
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ 'use strict'
4
+ /**
5
+ * llrt-analyzer CLI.
6
+ *
7
+ * llrt-analyzer <entryFile|serviceDir> [--local] [--project <dir>] [--fn <name>] [--json]
8
+ * llrt-analyzer init scaffold the serverless.yml snippet
9
+ * llrt-analyzer publish-layer [options] publish the LLRT layer + optionally write SSM
10
+ * llrt-analyzer --help
11
+ *
12
+ * The analyze path runs the static tier (add --local for the decisive
13
+ * run-under-LLRT tier) and prints a readable verdict + fixes + cold-start win.
14
+ */
15
+ const fs = require('fs')
16
+ const os = require('os')
17
+ const path = require('path')
18
+ const { spawnSync } = require('child_process')
19
+ const { analyzeFunction, analyzeService, ORG_PINNED_LLRT_VERSION } = require('./index')
20
+ const { download, sha256File } = require('./binary-manager')
21
+
22
+ function parseArgs(argv) {
23
+ const args = { _: [], flags: {} }
24
+ for (let i = 0; i < argv.length; i += 1) {
25
+ const a = argv[i]
26
+ if (a === '--help' || a === '-h') args.flags.help = true
27
+ else if (a === '--json') args.flags.json = true
28
+ else if (a === '--local') args.flags.local = true
29
+ else if (a === '--no-cache') args.flags.cache = false
30
+ else if (a === '--project') args.flags.project = argv[(i += 1)]
31
+ else if (a === '--fn') args.flags.fn = argv[(i += 1)]
32
+ else if (a === '--handler-export') args.flags.handlerExport = argv[(i += 1)]
33
+ else if (a === '--out') args.flags.out = argv[(i += 1)]
34
+ else if (a === '--cache-file') args.flags.cacheFile = argv[(i += 1)]
35
+ else if (a === '--arch') args.flags.arch = argv[(i += 1)]
36
+ else if (a === '--region') args.flags.region = argv[(i += 1)]
37
+ else if (a === '--profile') args.flags.profile = argv[(i += 1)]
38
+ else if (a === '--layer-name') args.flags.layerName = argv[(i += 1)]
39
+ else if (a === '--ssm') args.flags.ssm = argv[(i += 1)]
40
+ else if (a === '--stage') args.flags.stage = argv[(i += 1)]
41
+ else args._.push(a)
42
+ }
43
+ return args
44
+ }
45
+
46
+ const HELP = `llrt-analyzer — can this Lambda function run on AWS LLRT (~10x faster cold starts)?
47
+
48
+ Usage:
49
+ llrt-analyzer <entryFile|serviceDir> [options] analyze one function or a whole service
50
+ llrt-analyzer init print the serverless.yml snippet to flip functions
51
+ llrt-analyzer publish-layer [options] publish the LLRT layer (+ optionally write its ARN to SSM)
52
+
53
+ Analyze options:
54
+ --local Run the decisive tier: actually execute under the LLRT binary and diff behavior.
55
+ --project <dir> Project root (for relative file paths). Default: entry's dir.
56
+ --fn <name> Function name label. Default: entry basename.
57
+ --out <file> Write machine-readable result.json (service mode).
58
+ --json Emit the raw FunctionVerdict JSON.
59
+ --no-cache Ignore the local-tier result cache.
60
+ --cache-file <f> Use a specific cache store (default ~/.smart-ci/llrt/verdict-cache.json).
61
+
62
+ publish-layer options:
63
+ --arch <arm64|x64> Layer architecture (default arm64).
64
+ --region <region> AWS region (default us-east-1).
65
+ --profile <name> AWS profile.
66
+ --layer-name <name> Layer name (default llrt-<arch>).
67
+ --ssm <param> Also write the published ARN to this SSM parameter.
68
+
69
+ Example:
70
+ llrt-analyzer services/mcp-gateway --local --out result.json
71
+ `
72
+
73
+ async function main() {
74
+ const { _, flags } = parseArgs(process.argv.slice(2))
75
+ if (flags.help || _.length === 0) {
76
+ process.stdout.write(HELP)
77
+ return
78
+ }
79
+ if (_[0] === 'init') return cmdInit()
80
+ if (_[0] === 'publish-layer') return cmdPublishLayer(flags)
81
+
82
+ const target = path.resolve(_[0])
83
+ const tiers = flags.local ? ['static', 'local'] : ['static']
84
+ const isDir = fs.existsSync(target) && fs.statSync(target).isDirectory()
85
+
86
+ // Whole-service mode: a directory -> discover functions from serverless.yml.
87
+ if (isDir) {
88
+ const verdicts = await analyzeService({ projectDir: target, tiers, resultFile: flags.out, cache: flags.cache, cacheFile: flags.cacheFile })
89
+ if (!verdicts.length) {
90
+ process.stderr.write(`No functions discovered in ${target} (need a serverless.yml with functions).\n`)
91
+ process.exit(2)
92
+ }
93
+ if (flags.json) {
94
+ process.stdout.write(`${JSON.stringify(verdicts, null, 2)}\n`)
95
+ return
96
+ }
97
+ for (const v of verdicts) process.stdout.write(renderReport(v))
98
+ if (flags.out) process.stdout.write(`\nresult.json written to ${flags.out}\n`)
99
+ return
100
+ }
101
+
102
+ // Single-function mode.
103
+ const entryPoint = target
104
+ const projectDir = flags.project ? path.resolve(flags.project) : path.dirname(entryPoint)
105
+ const verdict = await analyzeFunction({ entryPoint, projectDir, fn: flags.fn, tiers, handlerExport: flags.handlerExport, cache: flags.cache, cacheFile: flags.cacheFile })
106
+ if (flags.json) {
107
+ process.stdout.write(`${JSON.stringify(verdict, null, 2)}\n`)
108
+ return
109
+ }
110
+ process.stdout.write(renderReport(verdict))
111
+ }
112
+
113
+ function renderReport(v) {
114
+ const icon = { switch: '🟢', 'compatible-but-not-recommended': '🟡', unknown: '⚪', incompatible: '🔴' }[v.recommendation]
115
+ const lines = []
116
+ lines.push('')
117
+ lines.push(`${icon} ${v.fn}: ${v.recommendation.toUpperCase()} (LLRT ${v.llrtVersion}, confidence: ${v.confidence}, static tier: ${v.staticTier})`)
118
+ lines.push(` sdk bundle: ${v.sdkBundle}${v.bundleIn.length ? ` bundleIn: ${v.bundleIn.join(', ')}` : ''}`)
119
+ const cs = v.coldStartDeltaMs
120
+ if (cs && cs.measured) {
121
+ lines.push(` cold start (init): node ~${cs.node}ms → llrt ~${cs.llrt}ms${cs.ratio ? ` (${cs.ratio}× faster)` : ''}`)
122
+ }
123
+ if (v.provenance && v.provenance.tier === 'local' && v.provenance.hostMatchesTarget === false) {
124
+ lines.push(` ⚠ ran on ${v.provenance.hostPlatform}/${v.provenance.hostArch}; Lambda is ${v.provenance.targetPlatform}/${v.provenance.targetArch} → confidence: ${v.confidence}`)
125
+ }
126
+ if (v.provenance && v.provenance.cached) lines.push(' (result served from cache — inputs unchanged)')
127
+ if (v.verified.length) lines.push(` verified: ${v.verified.join(', ')}`)
128
+ if (v.blockers.length) {
129
+ lines.push(' BLOCKERS:')
130
+ for (const b of v.blockers) lines.push(` ✗ ${b.api}${b.via && b.via !== '(direct)' ? ` (via ${b.via})` : ''}${b.file ? ` [${b.file}]` : ''}\n fix: ${b.fix}`)
131
+ }
132
+ if (v.warnings.length) {
133
+ lines.push(' WARNINGS:')
134
+ for (const w of v.warnings) lines.push(` ! ${w.note}${w.fix ? `\n fix: ${w.fix}` : ''}`)
135
+ }
136
+ if (v.diffs && v.diffs.length) {
137
+ lines.push(` BEHAVIORAL DIFFS (Node vs LLRT — same input, different output):`)
138
+ for (const d of v.diffs) {
139
+ lines.push(` ≠ ${d.event} ${d.path}`)
140
+ if (d.hint) lines.push(` ${d.hint}`)
141
+ }
142
+ }
143
+ if (v.recommendation === 'switch' || v.recommendation === 'compatible-but-not-recommended') {
144
+ lines.push(` → to flip: add \`llrt: true\` to the ${v.fn} function in serverless.yml`)
145
+ }
146
+ if (v.staticTier !== 'likely-incompatible' && !v.ranLocal) {
147
+ lines.push(' (static tier only — run the local call-comparison for a definitive verdict)')
148
+ }
149
+ lines.push('')
150
+ return lines.join('\n')
151
+ }
152
+
153
+ /** `init` — print the serverless.yml snippet (non-destructive; paste it in). */
154
+ function cmdInit() {
155
+ const snippet = `# --- llrt-analyzer: flip functions to AWS LLRT for ~10x faster cold starts ---
156
+ plugins:
157
+ - serverless-llrt-analyzer
158
+
159
+ custom:
160
+ llrt:
161
+ # Publish the layer once with: llrt-analyzer publish-layer --ssm /my-svc/\${self:provider.stage}/llrt-layer-arn
162
+ layerArn: \${ssm:/my-svc/\${self:provider.stage}/llrt-layer-arn}
163
+ verify: true # fail the deploy if a flagged function is LLRT-incompatible
164
+
165
+ functions:
166
+ # add \`llrt: true\` to any function you want on LLRT (mixed-runtime is fine):
167
+ myFunction:
168
+ handler: src/handler.main
169
+ llrt: true
170
+ `
171
+ process.stdout.write(snippet)
172
+ process.stdout.write('\n# Then run `llrt-analyzer <serviceDir> --local` to confirm each flagged function.\n')
173
+ }
174
+
175
+ /** `publish-layer` — download the LLRT lambda bootstrap, publish it as a layer,
176
+ * print the ARN, and optionally write it to SSM. Wraps the manual curl→publish→SSM dance. */
177
+ async function cmdPublishLayer(flags) {
178
+ const arch = flags.arch === 'x64' ? 'x64' : 'arm64'
179
+ const region = flags.region || 'us-east-1'
180
+ const version = ORG_PINNED_LLRT_VERSION
181
+ const layerName = flags.layerName || `llrt-${arch}`
182
+ const asset = `llrt-lambda-${arch}.zip`
183
+ const url = `https://github.com/awslabs/llrt/releases/download/v${version}/${asset}`
184
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'llrt-layer-'))
185
+ const zip = path.join(tmp, asset)
186
+
187
+ process.stderr.write(`[publish-layer] downloading ${asset} @ v${version}\n`)
188
+ await download(url, zip)
189
+ process.stderr.write(`[publish-layer] sha256(${asset}) = ${sha256File(zip)}\n`)
190
+
191
+ const runtimes = 'provided.al2023'
192
+ const awsArgs = [
193
+ 'lambda', 'publish-layer-version',
194
+ '--layer-name', layerName,
195
+ '--zip-file', `fileb://${zip}`,
196
+ '--compatible-architectures', arch,
197
+ '--compatible-runtimes', runtimes,
198
+ '--description', `AWS LLRT ${version} bootstrap (${arch})`,
199
+ '--region', region,
200
+ '--query', 'LayerVersionArn', '--output', 'text',
201
+ ]
202
+ if (flags.profile) awsArgs.push('--profile', flags.profile)
203
+ process.stderr.write(`[publish-layer] aws ${awsArgs.join(' ')}\n`)
204
+ const pub = spawnSync('aws', awsArgs, { encoding: 'utf8' })
205
+ if (pub.status !== 0) {
206
+ process.stderr.write(`[publish-layer] publish failed:\n${pub.stderr || pub.stdout}\n`)
207
+ process.exit(1)
208
+ }
209
+ const arn = (pub.stdout || '').trim()
210
+ process.stdout.write(`${arn}\n`)
211
+
212
+ if (flags.ssm) {
213
+ const ssmArgs = ['ssm', 'put-parameter', '--name', flags.ssm, '--type', 'String', '--overwrite', '--value', arn, '--region', region]
214
+ if (flags.profile) ssmArgs.push('--profile', flags.profile)
215
+ const put = spawnSync('aws', ssmArgs, { encoding: 'utf8' })
216
+ if (put.status !== 0) {
217
+ process.stderr.write(`[publish-layer] wrote layer but SSM put failed:\n${put.stderr || put.stdout}\n`)
218
+ process.exit(1)
219
+ }
220
+ process.stderr.write(`[publish-layer] wrote ARN to SSM ${flags.ssm}\n`)
221
+ } else {
222
+ process.stderr.write(`[publish-layer] set custom.llrt.layerArn to the ARN above (or re-run with --ssm <param>).\n`)
223
+ }
224
+ }
225
+
226
+ main().catch((err) => {
227
+ process.stderr.write(`llrt-analyzer error: ${err && err.stack ? err.stack : err}\n`)
228
+ process.exit(1)
229
+ })
@@ -0,0 +1,51 @@
1
+ // @ts-check
2
+ 'use strict'
3
+ /**
4
+ * Cold-start init benchmark (idea #3).
5
+ *
6
+ * The whole value prop is "~10x faster cold starts", but the verdict never
7
+ * showed a number. This measures the dominant cold-start cost — interpreter
8
+ * startup + module init — by spawning the verify bundle with LLRT_INIT_ONLY=1
9
+ * (which imports everything then exits BEFORE running the handler) under Node
10
+ * and under the LLRT binary, and timing wall-clock. We take the MIN over a few
11
+ * samples (least noisy = closest to the machine's best case) and report the
12
+ * ratio. This is a proxy for the real Lambda Init Duration; the opt-in deploy
13
+ * tier (deploy-verify.js) measures the real thing.
14
+ */
15
+ const { spawnSync } = require('child_process')
16
+
17
+ /**
18
+ * @param {{ cmd: string, bundleFile: string, samples?: number, timeoutMs?: number }} input
19
+ * @returns {number|null} best-case init time in ms, or null if it never succeeded
20
+ */
21
+ function timeInit(input) {
22
+ const samples = input.samples || 5
23
+ let best = null
24
+ for (let i = 0; i < samples; i += 1) {
25
+ const start = Date.now()
26
+ const res = spawnSync(input.cmd, [input.bundleFile], {
27
+ encoding: 'utf8',
28
+ timeout: input.timeoutMs || 20000,
29
+ env: { ...process.env, LLRT_INIT_ONLY: '1', VERIFY_EVENT_FILE: '' },
30
+ maxBuffer: 8 * 1024 * 1024,
31
+ })
32
+ const ms = Date.now() - start
33
+ // Only count runs that actually reached the init-only exit cleanly.
34
+ if (res.status === 0 && (best === null || ms < best)) best = ms
35
+ }
36
+ return best
37
+ }
38
+
39
+ /**
40
+ * Measure Node vs LLRT init for a built verify bundle.
41
+ * @param {{ nodeCmd?: string, llrtBin: string, bundleFile: string, samples?: number }} input
42
+ * @returns {{ node: number|null, llrt: number|null, ratio: number|null, measured: boolean }}
43
+ */
44
+ function measureColdStart(input) {
45
+ const node = timeInit({ cmd: input.nodeCmd || process.execPath, bundleFile: input.bundleFile, samples: input.samples })
46
+ const llrt = timeInit({ cmd: input.llrtBin, bundleFile: input.bundleFile, samples: input.samples })
47
+ const ratio = node != null && llrt != null && llrt > 0 ? Math.round((node / llrt) * 10) / 10 : null
48
+ return { node, llrt, ratio, measured: node != null && llrt != null }
49
+ }
50
+
51
+ module.exports = { measureColdStart, timeInit }
package/src/corpus.js ADDED
@@ -0,0 +1,116 @@
1
+ // @ts-check
2
+ 'use strict'
3
+ /**
4
+ * Verify corpus (beads sci-xwk.23 + .24). Events to replay under Node + LLRT.
5
+ * Fixtures first (deterministic, no PII): __fixtures__/llrt/<fn>/*.json or
6
+ * __fixtures__/llrt/*.json. Optional CloudWatch capture through a redaction
7
+ * pass. Falls back to a synthetic per-adapter event.
8
+ */
9
+ const fs = require('fs')
10
+ const path = require('path')
11
+
12
+ const REDACT_KEY = [/authoriz/i, /secret/i, /token/i, /password/i, /passwd/i, /email/i, /api[-_]?key/i, /credential/i, /cookie/i, /session/i, /ssn/i]
13
+
14
+ /** Deep-redact values of sensitive-looking keys. Returns a new object. */
15
+ function redact(value) {
16
+ if (Array.isArray(value)) return value.map(redact)
17
+ if (value && typeof value === 'object') {
18
+ const out = {}
19
+ for (const k of Object.keys(value)) out[k] = REDACT_KEY.some((re) => re.test(k)) ? '__REDACTED__' : redact(value[k])
20
+ return out
21
+ }
22
+ return value
23
+ }
24
+
25
+ /** Minimal API Gateway v2 (HTTP API) event. */
26
+ function synthApiGwV2(method, routePath) {
27
+ return {
28
+ version: '2.0',
29
+ routeKey: `${method} ${routePath}`,
30
+ rawPath: routePath,
31
+ rawQueryString: '',
32
+ headers: { 'content-type': 'application/json' },
33
+ requestContext: { http: { method, path: routePath, sourceIp: '127.0.0.1' }, requestId: 'verify', stage: '$default' },
34
+ isBase64Encoded: false,
35
+ }
36
+ }
37
+
38
+ const SYNTH = {
39
+ httpApi: [synthApiGwV2('GET', '/'), synthApiGwV2('GET', '/health')],
40
+ sqs: [{ Records: [{ messageId: 'verify', body: '{}', attributes: {}, messageAttributes: {} }] }],
41
+ eventbridge: [{ version: '0', 'detail-type': 'verify', source: 'verify', detail: {} }],
42
+ s3: [{ Records: [{ eventName: 'ObjectCreated:Put', s3: { bucket: { name: 'verify' }, object: { key: 'k' } } }] }],
43
+ generic: [{}],
44
+ }
45
+
46
+ /**
47
+ * @param {{ projectDir: string, fnName?: string, adapter?: keyof typeof SYNTH, explicit?: any[] }} input
48
+ * @returns {any[]}
49
+ */
50
+ function loadCorpus(input) {
51
+ if (input.explicit && input.explicit.length) return input.explicit
52
+ const roots = [
53
+ input.fnName && path.join(input.projectDir, '__fixtures__', 'llrt', input.fnName),
54
+ path.join(input.projectDir, '__fixtures__', 'llrt'),
55
+ ].filter(Boolean)
56
+ for (const dir of roots) {
57
+ const events = readJsonDir(dir)
58
+ if (events.length) return events
59
+ }
60
+ return SYNTH[input.adapter || 'httpApi'] || SYNTH.generic
61
+ }
62
+
63
+ function readJsonDir(dir) {
64
+ let entries
65
+ try {
66
+ entries = fs.readdirSync(dir)
67
+ } catch (_) {
68
+ return []
69
+ }
70
+ const out = []
71
+ for (const name of entries) {
72
+ if (!name.endsWith('.json')) continue
73
+ try {
74
+ out.push(JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8')))
75
+ } catch (_) {
76
+ /* skip malformed */
77
+ }
78
+ }
79
+ return out
80
+ }
81
+
82
+ /**
83
+ * Best-effort capture of recent invocation events from CloudWatch Logs, redacted.
84
+ * Requires @aws-sdk/client-cloudwatch-logs + AWS creds; throws a clear error
85
+ * otherwise (fixtures remain the default).
86
+ * @param {{ logGroup: string, limit?: number, region?: string }} input
87
+ * @returns {Promise<any[]>}
88
+ */
89
+ async function captureFromCloudWatch(input) {
90
+ let CWL
91
+ try {
92
+ const modName = '@aws-sdk/client-cloudwatch-logs' // indirect so tsc/bundlers don't require it
93
+ // eslint-disable-next-line global-require, import/no-dynamic-require
94
+ CWL = require(modName)
95
+ } catch (_) {
96
+ throw new Error('captureFromCloudWatch requires @aws-sdk/client-cloudwatch-logs to be installed.')
97
+ }
98
+ const client = new CWL.CloudWatchLogsClient({ region: input.region })
99
+ const res = await client.send(
100
+ new CWL.FilterLogEventsCommand({ logGroupName: input.logGroup, limit: input.limit || 25, filterPattern: '' }),
101
+ )
102
+ const events = []
103
+ for (const e of res.events || []) {
104
+ const m = String(e.message || '')
105
+ const start = m.indexOf('{')
106
+ if (start < 0) continue
107
+ try {
108
+ events.push(redact(JSON.parse(m.slice(start))))
109
+ } catch (_) {
110
+ /* not JSON */
111
+ }
112
+ }
113
+ return events
114
+ }
115
+
116
+ module.exports = { loadCorpus, redact, synthApiGwV2, captureFromCloudWatch }
@@ -0,0 +1,65 @@
1
+ // @ts-check
2
+ 'use strict'
3
+ /**
4
+ * Tier-3 deploy-verify (bead sci-xwk.33) — OPT-IN, heavy. The local tier
5
+ * (call-comparison) is the default and covers correctness with no side effects;
6
+ * this tier exists for the extra confidence of running on real Lambda + a real
7
+ * cold-start measurement. It is intentionally gated (slow, costs AWS).
8
+ *
9
+ * Flow: deploy the LLRT variant to an ISOLATED ephemeral stage (own sandbox
10
+ * resources), replay the corpus + run the service's integration tests, MEASURE
11
+ * cold starts (Node vs LLRT), then tear down. Deploy/teardown are delegated to
12
+ * the Serverless Framework via child_process; measurement is implemented here.
13
+ */
14
+ const { spawnSync } = require('child_process')
15
+
16
+ /**
17
+ * Measure cold-start Init Duration by invoking a deployed function with a fresh
18
+ * container N times (forcing cold starts via an env bump between calls is
19
+ * service-specific; here we read Init Duration from the tail logs of cold
20
+ * invocations). Requires @aws-sdk/client-lambda + creds.
21
+ * @param {{ functionName: string, region?: string, samples?: number }} input
22
+ * @returns {Promise<{ p50: number|null, samples: number[] }>}
23
+ */
24
+ async function measureColdStart(input) {
25
+ let Lambda
26
+ try {
27
+ const mod = '@aws-sdk/client-lambda'
28
+ // eslint-disable-next-line global-require, import/no-dynamic-require
29
+ Lambda = require(mod)
30
+ } catch (_) {
31
+ throw new Error('measureColdStart requires @aws-sdk/client-lambda + AWS credentials.')
32
+ }
33
+ const client = new Lambda.LambdaClient({ region: input.region })
34
+ const samples = []
35
+ const n = input.samples || 10
36
+ for (let i = 0; i < n; i += 1) {
37
+ // eslint-disable-next-line no-await-in-loop
38
+ const res = await client.send(new Lambda.InvokeCommand({ FunctionName: input.functionName, LogType: 'Tail', Payload: Buffer.from('{}') }))
39
+ const log = res.LogResult ? Buffer.from(res.LogResult, 'base64').toString('utf8') : ''
40
+ const m = log.match(/Init Duration: ([0-9.]+) ms/)
41
+ if (m) samples.push(Number(m[1]))
42
+ }
43
+ samples.sort((a, b) => a - b)
44
+ const p50 = samples.length ? samples[Math.floor(samples.length / 2)] : null
45
+ return { p50, samples }
46
+ }
47
+
48
+ /**
49
+ * Deploy the service to an ephemeral stage, run the caller's verify callback,
50
+ * then tear down. The heavy orchestration (sandbox resources) is service-owned;
51
+ * this wraps deploy/remove so a caller can run replay + integration tests + a
52
+ * cold-start measurement in between.
53
+ * @param {{ serviceDir: string, stage: string, verify: () => Promise<any> }} input
54
+ */
55
+ async function withEphemeralStage(input) {
56
+ const deploy = spawnSync('npx', ['serverless', 'deploy', '--stage', input.stage], { cwd: input.serviceDir, encoding: 'utf8', stdio: 'inherit' })
57
+ if (deploy.status !== 0) throw new Error(`ephemeral deploy failed (stage ${input.stage})`)
58
+ try {
59
+ return await input.verify()
60
+ } finally {
61
+ spawnSync('npx', ['serverless', 'remove', '--stage', input.stage], { cwd: input.serviceDir, encoding: 'utf8', stdio: 'inherit' })
62
+ }
63
+ }
64
+
65
+ module.exports = { measureColdStart, withEphemeralStage }
package/src/diff.js ADDED
@@ -0,0 +1,113 @@
1
+ // @ts-check
2
+ 'use strict'
3
+ /**
4
+ * Semantic diff (bead sci-xwk.20). Compare a Node run vs an LLRT run:
5
+ * - downstream calls compared by kind + service/command/method/url + input,
6
+ * ignoring SDK-internal noise (LLRT ships a different SDK build);
7
+ * - HTTP responses compared after normalizing a denylist of volatile fields.
8
+ * Returns diffs[]; empty = behaved identically.
9
+ */
10
+
11
+ const VOLATILE_KEYS = [/date/i, /time/i, /timestamp/i, /requestid/i, /x-amz-/i, /etag/i, /^id$/i, /Id$/, /trace/i, /nonce/i]
12
+
13
+ function isVolatileKey(k) {
14
+ return VOLATILE_KEYS.some((re) => re.test(String(k)))
15
+ }
16
+
17
+ /** Strip volatile keys + sort, recursively, for stable comparison. */
18
+ function normalize(value) {
19
+ if (Array.isArray(value)) return value.map(normalize)
20
+ if (value && typeof value === 'object') {
21
+ const out = {}
22
+ for (const k of Object.keys(value).sort()) {
23
+ if (isVolatileKey(k)) continue
24
+ out[k] = normalize(value[k])
25
+ }
26
+ return out
27
+ }
28
+ return value
29
+ }
30
+
31
+ /** Canonical key for a downstream call (order-independent multiset compare). */
32
+ function callKey(c) {
33
+ if (c.kind === 'aws') return `aws:${c.service}.${c.command}:${stable(normalize(c.input))}`
34
+ if (c.kind === 'fetch') return `fetch:${c.method}:${stripQueryVolatile(c.url)}:${c.body ? stable(c.body) : ''}`
35
+ return `other:${stable(c)}`
36
+ }
37
+ function stripQueryVolatile(url) {
38
+ try {
39
+ const u = new URL(url)
40
+ return `${u.origin}${u.pathname}`
41
+ } catch (_) {
42
+ return String(url).split('?')[0]
43
+ }
44
+ }
45
+ function stable(v) {
46
+ return typeof v === 'string' ? v : JSON.stringify(normalize(v))
47
+ }
48
+
49
+ /**
50
+ * @param {string} event label for reporting
51
+ * @param {{ok:boolean,response?:any,calls:any[],error?:string}} node
52
+ * @param {{ok:boolean,response?:any,calls:any[],error?:string}} llrt
53
+ * @returns {import('./verdict').CallDiff[]}
54
+ */
55
+ function diffRun(event, node, llrt) {
56
+ const diffs = []
57
+ // 1. one crashed, the other didn't
58
+ if (node.ok !== llrt.ok) {
59
+ diffs.push({ event, path: '$run', node: node.ok ? 'ok' : `error: ${node.error}`, llrt: llrt.ok ? 'ok' : `error: ${llrt.error}` })
60
+ return diffs // no point diffing further
61
+ }
62
+ if (!node.ok && !llrt.ok) return diffs // both failed the same way (attributed elsewhere)
63
+
64
+ // 2. downstream call multiset
65
+ const nk = (node.calls || []).map(callKey).sort()
66
+ const lk = (llrt.calls || []).map(callKey).sort()
67
+ if (stable(nk) !== stable(lk)) {
68
+ diffs.push({ event, path: '$calls', node: nk, llrt: lk })
69
+ }
70
+
71
+ // 3. response
72
+ const rn = stable(normalize(node.response))
73
+ const rl = stable(normalize(llrt.response))
74
+ if (rn !== rl) {
75
+ const d = { event, path: '$response', node: normalize(node.response), llrt: normalize(llrt.response) }
76
+ const hint = explainResponseDiff(d)
77
+ if (hint) d.hint = hint
78
+ diffs.push(d)
79
+ }
80
+ return diffs
81
+ }
82
+
83
+ /**
84
+ * Attribute well-understood LLRT behavioral diffs to a root cause + fix, so a
85
+ * `$response` diff isn't just raw JSON the user has to decode. Empirically
86
+ * discovered + verified on real Lambda (see docs/PILOT-mcp-gateway.md).
87
+ * @returns {string} explanation, or '' if the pattern isn't recognized
88
+ */
89
+ function explainResponseDiff(d) {
90
+ const n = d.node && typeof d.node === 'object' ? d.node : {}
91
+ const l = d.llrt && typeof d.llrt === 'object' ? d.llrt : {}
92
+ const nodeBody = typeof n.body === 'string' ? n.body : ''
93
+ const llrtBody = typeof l.body === 'string' ? l.body : ''
94
+ // Same status, headers largely intact, but LLRT returns an empty body where
95
+ // Node returned one: LLRT 0.8.x does not implement Response.prototype.body
96
+ // (the ReadableStream getter returns undefined). Any framework that re-wraps
97
+ // a response via `new Response(res.body, init)` — e.g. Hono when middleware
98
+ // mutates headers post-handler (CORS) — then produces `new Response(undefined)`
99
+ // → empty body. `.text()`/`.arrayBuffer()` still work; only `.body` is missing.
100
+ if (nodeBody.length > 0 && llrtBody.length === 0 && n.statusCode === l.statusCode) {
101
+ return (
102
+ 'LLRT dropped the response body (Node returned one). Root cause: LLRT 0.8.x ' +
103
+ 'does not implement Response.prototype.body (ReadableStream) — it returns undefined. ' +
104
+ 'Frameworks that re-wrap a response from res.body (Hono when header-mutating ' +
105
+ 'middleware like CORS runs after the handler) yield an empty body under LLRT. ' +
106
+ 'Fix: avoid reading Response#body (return the string/JSON directly, or drop the ' +
107
+ 'post-handler header mutation), or keep this function on Node until LLRT ships it.'
108
+ )
109
+ }
110
+ return ''
111
+ }
112
+
113
+ module.exports = { diffRun, normalize, explainResponseDiff }
@@ -0,0 +1,38 @@
1
+ // @ts-check
2
+ 'use strict'
3
+ /**
4
+ * Error-signature -> offending API attribution (bead sci-xwk.21). Turns an
5
+ * opaque LLRT runtime error into an actionable blocker with a fix.
6
+ */
7
+ const SIGNATURES = [
8
+ {
9
+ // esbuild bundled `import { X } from 'Y'` but LLRT's Y has no export X.
10
+ re: /Could not find export '([^']+)' in module '([^']+)'/i,
11
+ derive: (m) => ({
12
+ api: `node:${m[2]}.${m[1]}`,
13
+ fix: `LLRT's node:${m[2]} does not export "${m[1]}" — avoid that call (e.g. use a supported alternative) or keep this function on Node.`,
14
+ }),
15
+ },
16
+ { re: /\bhttp\b.*(not implemented|unsupported)|createServer is not a function/i, api: 'node:http', fix: 'node:http is not implemented in LLRT — use global fetch, or keep this function on Node.' },
17
+ { re: /createDiffieHellman is not a function/i, api: 'crypto.createDiffieHellman', fix: 'no LLRT equivalent — move Diffie-Hellman off this path or keep on Node.' },
18
+ { re: /generatePrime.*is not a function/i, api: 'crypto.generatePrime', fix: 'not available under LLRT — keep on Node.' },
19
+ { re: /(createCipheriv|createDecipheriv|scrypt|pbkdf2).*is not a function/i, api: 'crypto.<cipher/kdf>', fix: 'this crypto primitive is not available under LLRT — keep on Node or use a supported algorithm.' },
20
+ { re: /Worker is not (defined|a constructor)/i, api: 'worker_threads', fix: 'worker_threads is unsupported in LLRT.' },
21
+ { re: /Cannot find module|Unable to resolve|Unknown module|Module not found/i, api: 'module-resolution', fix: 'a dependency or builtin the handler imports is not available under LLRT — check the static blockers.' },
22
+ { re: /is not a function/i, api: 'unknown-api', fix: 'an API the handler calls is missing under LLRT — run with LLRT_ASYNC_HOOKS/verbose to locate it.' },
23
+ ]
24
+
25
+ /**
26
+ * @param {string} errText
27
+ * @returns {{ api: string, fix: string }|null}
28
+ */
29
+ function attributeError(errText) {
30
+ if (!errText) return null
31
+ for (const s of SIGNATURES) {
32
+ const m = errText.match(s.re)
33
+ if (m) return s.derive ? s.derive(m) : { api: s.api, fix: s.fix }
34
+ }
35
+ return null
36
+ }
37
+
38
+ module.exports = { attributeError }