@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.
@@ -0,0 +1,52 @@
1
+ // @ts-check
2
+ 'use strict'
3
+ /**
4
+ * Handler invocation shim (bead sci-xwk.17). Run the verify bundle under a
5
+ * runtime (Node or the LLRT binary) with a corpus event, capture the JSON
6
+ * result the generated entry prints (recorded calls + response, or the error).
7
+ */
8
+ const fs = require('fs')
9
+ const os = require('os')
10
+ const path = require('path')
11
+ const { spawnSync } = require('child_process')
12
+
13
+ /**
14
+ * @param {{ runtime: 'node'|'llrt', bin?: string, bundleFile: string, event?: any, resultMarker: string, timeoutMs?: number }} input
15
+ * @returns {{ ok: boolean, response?: any, calls: any[], error?: string, stack?: string, exit: number|null, stderr?: string, raw?: string }}
16
+ */
17
+ function runUnder(input) {
18
+ const cmd = input.runtime === 'llrt' ? input.bin : process.execPath
19
+ if (!cmd) throw new Error('runUnder: missing binary for runtime ' + input.runtime)
20
+
21
+ let eventFile
22
+ if (input.event !== undefined) {
23
+ eventFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'llrt-evt-')), 'event.json')
24
+ fs.writeFileSync(eventFile, JSON.stringify(input.event))
25
+ }
26
+ const res = spawnSync(cmd, [input.bundleFile], {
27
+ encoding: 'utf8',
28
+ timeout: input.timeoutMs || 20000,
29
+ env: { ...process.env, VERIFY_EVENT_FILE: eventFile || '' },
30
+ maxBuffer: 16 * 1024 * 1024,
31
+ })
32
+ const out = res.stdout || ''
33
+ const line = out.split('\n').find((l) => l.startsWith(input.resultMarker))
34
+ if (!line) {
35
+ return {
36
+ ok: false,
37
+ calls: [],
38
+ error: `no result from ${input.runtime} (exit=${res.status}${res.signal ? `, signal=${res.signal}` : ''})`,
39
+ stderr: (res.stderr || '').slice(0, 2000),
40
+ raw: out.slice(0, 500),
41
+ exit: res.status,
42
+ }
43
+ }
44
+ try {
45
+ const parsed = JSON.parse(line.slice(input.resultMarker.length))
46
+ return { calls: [], ...parsed, exit: res.status, stderr: res.stderr }
47
+ } catch (err) {
48
+ return { ok: false, calls: [], error: `bad result JSON from ${input.runtime}: ${err.message}`, exit: res.status }
49
+ }
50
+ }
51
+
52
+ module.exports = { runUnder }
@@ -0,0 +1,99 @@
1
+ 'use strict'
2
+ /**
3
+ * AWS SDK recorder shim (bead sci-xwk.16). The verify bundle aliases every
4
+ * @aws-sdk/* and @smithy/* import to THIS module so that, under BOTH Node and
5
+ * LLRT, SDK calls are captured SEMANTICALLY (client + command + input) and
6
+ * return a canned result — no real network, no side effects, uniform capture.
7
+ *
8
+ * A Proxy satisfies arbitrary named imports (`{ DynamoDBClient, GetItemCommand }`)
9
+ * without us enumerating every client/command. Client instances record each
10
+ * `.send(command)` to globalThis.__LLRT_CALLS__.
11
+ */
12
+ function record(entry) {
13
+ const g = globalThis
14
+ if (!g.__LLRT_CALLS__) g.__LLRT_CALLS__ = []
15
+ g.__LLRT_CALLS__.push(entry)
16
+ }
17
+
18
+ function makeExport(name) {
19
+ // Heuristic: names ending in "Client" are clients; "Command" are commands;
20
+ // everything else (helpers, enums) becomes a permissive no-op function/object.
21
+ if (/Client$/.test(name)) {
22
+ const RecordingClient = class RecordingClient {
23
+ constructor(config) {
24
+ this.__service = name.replace(/Client$/, '')
25
+ this.config = config || {}
26
+ }
27
+
28
+ async send(command) {
29
+ const commandName = (command && command.constructor && command.constructor.__cmdName) || (command && command.__cmdName) || 'UnknownCommand'
30
+ record({ kind: 'aws', service: this.__service, command: commandName, input: (command && command.input) || {} })
31
+ return {} // canned empty result
32
+ }
33
+
34
+ destroy() {}
35
+ }
36
+ // Some clients expose static FACTORY methods, most notably lib-dynamodb's
37
+ // `DynamoDBDocumentClient.from(baseClient, opts)`. We can't enumerate them,
38
+ // so trap unknown static access and return a factory that yields a
39
+ // recording client instance — preserving `.send()` capture downstream.
40
+ return new Proxy(RecordingClient, {
41
+ get(target, prop, receiver) {
42
+ if (prop in target || typeof prop === 'symbol') return Reflect.get(target, prop, receiver)
43
+ return (...args) => new RecordingClient(args[0])
44
+ },
45
+ })
46
+ }
47
+ if (/Command$/.test(name)) {
48
+ const Cmd = class RecordingCommand {
49
+ constructor(input) {
50
+ this.input = input || {}
51
+ }
52
+ }
53
+ Cmd.__cmdName = name
54
+ // Also stamp instances so send() can read the command name.
55
+ Object.defineProperty(Cmd.prototype, 'constructor', { value: Cmd })
56
+ Cmd.prototype.constructor.__cmdName = name
57
+ return Cmd
58
+ }
59
+ // Generic permissive value: callable + indexable.
60
+ const fn = function passthrough() {
61
+ return {}
62
+ }
63
+ return new Proxy(fn, { get: () => makeExport('Helper'), apply: () => ({}) })
64
+ }
65
+
66
+ const cache = new Map()
67
+ function lookup(prop) {
68
+ if (typeof prop !== 'string') return undefined
69
+ if (prop === '__esModule') return true
70
+ if (!cache.has(prop)) cache.set(prop, makeExport(prop))
71
+ return cache.get(prop)
72
+ }
73
+
74
+ // The consuming bundle is ESM (esbuild), so `import { SSMClient } from '@aws-sdk/...'`
75
+ // compiles to `ns.SSMClient` where `ns = __toESM(require(<this>))`. esbuild's
76
+ // __toESM builds `Object.create(getPrototypeOf(mod))` then copies mod's OWN
77
+ // enumerable props — a bare `get`-only Proxy exposes ZERO own props, so every
78
+ // named import would resolve to undefined ("not a function" at `new X()"). To
79
+ // survive that interop we put the name-resolving trap on the PROTOTYPE: __toESM
80
+ // preserves the prototype, so `ns.SSMClient` walks the chain into this trap.
81
+ // (Works identically under Node and LLRT/QuickJS.)
82
+ const nsProto = new Proxy(
83
+ {},
84
+ {
85
+ get(_t, prop) {
86
+ return lookup(prop)
87
+ },
88
+ },
89
+ )
90
+
91
+ module.exports = new Proxy(
92
+ Object.create(nsProto),
93
+ {
94
+ get(_target, prop) {
95
+ if (prop === 'default') return module.exports
96
+ return lookup(prop)
97
+ },
98
+ },
99
+ )
package/src/service.js ADDED
@@ -0,0 +1,77 @@
1
+ // @ts-check
2
+ 'use strict'
3
+ /**
4
+ * Service discovery + result emission (beads sci-xwk.28 + .26).
5
+ * Parse a Serverless-Framework service's functions -> entrypoints, and write the
6
+ * machine-readable verdict (result.json.llrtCandidates[]) for CI to jq.
7
+ */
8
+ const fs = require('fs')
9
+ const path = require('path')
10
+
11
+ const SOURCE_EXTS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs']
12
+
13
+ /**
14
+ * Discover Lambda functions + their handler entrypoints from serverless.yml.
15
+ * @param {string} serviceDir
16
+ * @returns {{ fn: string, entryPoint: string, handlerExport: string }[]}
17
+ */
18
+ function discoverFromServerless(serviceDir) {
19
+ const ymlPath = ['serverless.yml', 'serverless.yaml'].map((f) => path.join(serviceDir, f)).find((p) => fs.existsSync(p))
20
+ if (!ymlPath) return []
21
+ // eslint-disable-next-line global-require
22
+ const YAML = require('yaml')
23
+ let doc
24
+ try {
25
+ // Strip CloudFormation short tags (!Ref/!Sub/!GetAtt/...) so the parser
26
+ // doesn't warn; we only read functions.*.handler.
27
+ const raw = fs.readFileSync(ymlPath, 'utf8').replace(/!(?:Ref|Sub|GetAtt|Equals|Join|Select|Split|FindInMap|If|Not|And|Or|Base64|Cidr|ImportValue|Condition|Transform|GetAZs)\b/g, '')
28
+ doc = YAML.parse(raw)
29
+ } catch (_) {
30
+ return []
31
+ }
32
+ const functions = (doc && doc.functions) || {}
33
+ const out = []
34
+ for (const [fn, def] of Object.entries(functions)) {
35
+ const handler = def && /** @type {any} */ (def).handler
36
+ if (!handler || typeof handler !== 'string') continue
37
+ const dot = handler.lastIndexOf('.')
38
+ const fileRel = handler.slice(0, dot)
39
+ const handlerExport = handler.slice(dot + 1)
40
+ const entryPoint = resolveEntry(path.join(serviceDir, fileRel))
41
+ if (entryPoint) out.push({ fn, entryPoint, handlerExport })
42
+ }
43
+ return out
44
+ }
45
+
46
+ function resolveEntry(base) {
47
+ for (const e of SOURCE_EXTS) {
48
+ if (fs.existsSync(base + e)) return base + e
49
+ }
50
+ for (const e of SOURCE_EXTS) {
51
+ const idx = path.join(base, `index${e}`)
52
+ if (fs.existsSync(idx)) return idx
53
+ }
54
+ return null
55
+ }
56
+
57
+ /**
58
+ * @param {import('./verdict').FunctionVerdict[]} verdicts
59
+ * @param {string} outFile
60
+ */
61
+ function writeResultJson(verdicts, outFile) {
62
+ const payload = {
63
+ tool: 'llrt-analyzer',
64
+ generatedTiers: verdicts.some((v) => v.ranLocal) ? ['static', 'local'] : ['static'],
65
+ summary: {
66
+ total: verdicts.length,
67
+ switch: verdicts.filter((v) => v.recommendation === 'switch').length,
68
+ incompatible: verdicts.filter((v) => v.recommendation === 'incompatible').length,
69
+ unknown: verdicts.filter((v) => v.recommendation === 'unknown').length,
70
+ },
71
+ llrtCandidates: verdicts,
72
+ }
73
+ fs.writeFileSync(outFile, `${JSON.stringify(payload, null, 2)}\n`)
74
+ return payload
75
+ }
76
+
77
+ module.exports = { discoverFromServerless, writeResultJson }
@@ -0,0 +1,34 @@
1
+ // @ts-check
2
+ 'use strict'
3
+ /**
4
+ * smart-ci validator adapter (bead sci-xwk.34). Wraps the analyzer to return
5
+ * smart-ci's canonical validator shape so it can slot into src/validate/index.js
6
+ * behind a `checks.llrtCompat` toggle. Wiring (one line in the main CLI):
7
+ *
8
+ * // src/validate/llrt-compat.js
9
+ * module.exports = require('llrt-analyzer/src/smart-ci-check')
10
+ * // src/validate/index.js
11
+ * if (checks.llrtCompat) results.push(await require('./llrt-compat').validateLlrtCompatibility({ projectDir, functions }))
12
+ */
13
+ const { analyzeService } = require('./index')
14
+
15
+ /**
16
+ * @param {{ projectDir: string, functions?: {fn:string,entryPoint:string,handlerExport?:string}[], tiers?: string[] }} input
17
+ * @returns {Promise<{ valid: boolean, errors: {check:string, entryPoint:string, file?:string, error:string, fatal?:boolean}[], verified: string[] }>}
18
+ */
19
+ async function validateLlrtCompatibility(input) {
20
+ const verdicts = await analyzeService({ projectDir: input.projectDir, functions: input.functions, tiers: input.tiers || ['static'] })
21
+ const errors = []
22
+ const verified = []
23
+ for (const v of verdicts) {
24
+ for (const b of v.blockers) {
25
+ errors.push({ check: 'llrt-compat', entryPoint: v.fn, file: b.file, error: `${b.api}: ${b.fix}`, fatal: b.fatal })
26
+ }
27
+ // A recommendation of "switch" is a non-fatal informational signal that the
28
+ // function is LLRT-ready (a candidate to flip for faster cold starts).
29
+ if (v.recommendation === 'switch' || v.recommendation === 'compatible-but-not-recommended') verified.push(v.fn)
30
+ }
31
+ return { valid: errors.every((e) => !e.fatal), errors, verified }
32
+ }
33
+
34
+ module.exports = { validateLlrtCompatibility }
@@ -0,0 +1,195 @@
1
+ // @ts-check
2
+ 'use strict'
3
+ /**
4
+ * Tier-1 static scan (beads sci-xwk.7–.13): classify node: builtins, guard
5
+ * against disallowed API usage (runs every time), resolve the AWS-SDK bundle,
6
+ * flag known-bad npm + compute-heavy workloads, and attach a concrete fix to
7
+ * every finding. Assembles a partial FunctionVerdict (no execution required).
8
+ */
9
+ const fs = require('fs')
10
+ const path = require('path')
11
+ const { resolveImportGraph } = require('./import-graph')
12
+ const kb = require('./knowledge-base')
13
+ const { emptyVerdict } = require('./verdict')
14
+
15
+ /** Specific unsupported CALLS inside otherwise-partial modules + hard guards.
16
+ * This scan runs on every invocation regardless of any cached verdict, so a
17
+ * pure-code change that introduces an unsupported API is caught immediately. */
18
+ const DISALLOWED = [
19
+ { re: /\b(?:https?|node:https?)\s*\.\s*createServer\s*\(/, api: 'http.createServer', fix: 'node:http server is not implemented in LLRT — this handler cannot host a server; keep it on Node.' },
20
+ { re: /\bnew\s+Worker\s*\(/, api: 'worker_threads.Worker', fix: 'worker_threads is unsupported in LLRT — remove the worker or keep this function on Node.' },
21
+ { re: /\bcrypto\s*\.\s*createDiffieHellman\s*\(/, api: 'crypto.createDiffieHellman', fix: 'no LLRT equivalent — move Diffie-Hellman off this path or keep on Node.' },
22
+ { re: /\bcrypto\s*\.\s*generatePrime(Sync)?\s*\(/, api: 'crypto.generatePrime', fix: 'not available under LLRT — keep on Node.' },
23
+ { re: /\brequire\(\s*['"]node:vm['"]\s*\)|from\s+['"]node:vm['"]/, api: 'node:vm', fix: 'node:vm is unsupported in LLRT.' },
24
+ { re: /\bchild_process\s*\.\s*(exec|spawn|fork)\s*\(/, api: 'child_process', fix: 'child_process is (effectively) unsupported in LLRT — keep on Node.' },
25
+ ]
26
+
27
+ /** @param {string} name */
28
+ function fixForBuiltin(name) {
29
+ const map = {
30
+ http: 'node:http is not implemented in LLRT — use global fetch, or keep this function on Node.',
31
+ https: 'node:https is not implemented — use global fetch, or keep on Node.',
32
+ net: 'raw sockets are not implemented in LLRT.',
33
+ tls: 'node:tls is not implemented — outbound TLS goes through fetch.',
34
+ worker_threads: 'worker_threads is unsupported in LLRT (no multithreading).',
35
+ child_process: 'child_process is unsupported for LLRT handlers.',
36
+ vm: 'node:vm is unsupported in LLRT.',
37
+ cluster: 'node:cluster is unsupported (single-invocation Lambda anyway).',
38
+ http2: 'node:http2 is unsupported in LLRT.',
39
+ dgram: 'node:dgram (UDP) is unsupported in LLRT.',
40
+ dns: 'node:dns is unsupported in LLRT.',
41
+ }
42
+ return map[name] || `node:${name} is not available under LLRT — keep this function on Node or remove the dependency.`
43
+ }
44
+
45
+ /**
46
+ * @param {{ entryPoint: string, projectDir?: string, llrtVersion?: string, fn?: string }} input
47
+ * @returns {import('./verdict').FunctionVerdict}
48
+ */
49
+ function staticScan(input) {
50
+ const version = input.llrtVersion || kb.ORG_PINNED_LLRT_VERSION
51
+ const projectDir = input.projectDir || path.dirname(input.entryPoint)
52
+ const v = emptyVerdict(input.fn || path.basename(input.entryPoint), { llrtVersion: version })
53
+
54
+ const graph = resolveImportGraph(input.entryPoint, { projectDir })
55
+
56
+ // --- (sci-xwk.7) node: builtin classification ---
57
+ for (const b of graph.builtins) {
58
+ const status = kb.builtinStatus(b.name, version)
59
+ if (status === 'unsupported' || status === 'planned') {
60
+ v.blockers.push({ api: `node:${b.name}`, file: b.file, via: b.via, fatal: true, fix: fixForBuiltin(b.name) })
61
+ } else if (status === 'partial') {
62
+ v.warnings.push({ kind: 'partial-api', file: b.file, note: `node:${b.name} is partial under LLRT — ${kb.builtinNote(b.name, version) || 'confirm the specific calls by running it'}`, fix: 'run the local call-comparison (Tier 2) to confirm the exact APIs used behave identically.' })
63
+ } else if (status === 'supported') {
64
+ v.verified.push(`node:${b.name}`)
65
+ } else {
66
+ v.warnings.push({ kind: 'partial-api', file: b.file, note: `node:${b.name} not in the LLRT ${version} compat table — treat as unknown; run to confirm.` })
67
+ }
68
+ }
69
+
70
+ // --- (sci-xwk.8) disallowed-API scan (every run guard) — only reachable first-party files ---
71
+ const firstParty = graph.firstPartyFiles && graph.firstPartyFiles.length ? graph.firstPartyFiles : [input.entryPoint]
72
+ for (const hit of disallowedApiScan(firstParty, projectDir)) {
73
+ if (!v.blockers.some((b) => b.api === hit.api)) {
74
+ v.blockers.push({ api: hit.api, file: hit.file, fatal: true, fix: hit.fix })
75
+ }
76
+ }
77
+
78
+ // --- known-quirks scan: confirmed BEHAVIORAL breaks that plain import/builtin
79
+ // analysis can't see (e.g. hono/cors emptying the body under LLRT). Seeded
80
+ // from empirical findings so the static tier + deploy guard catch them cheaply. ---
81
+ for (const hit of knownQuirksScan(firstParty, projectDir, version)) {
82
+ if (hit.severity === 'blocker') {
83
+ if (!v.blockers.some((b) => b.api === hit.api)) {
84
+ v.blockers.push({ api: hit.api, file: hit.file, via: hit.id, fatal: true, fix: hit.fix })
85
+ }
86
+ } else if (!v.warnings.some((w) => w.note === hit.note)) {
87
+ v.warnings.push({ kind: 'partial-api', file: hit.file, note: `${hit.api}: ${hit.note}`, fix: hit.fix })
88
+ }
89
+ }
90
+
91
+ // --- (sci-xwk.9) AWS SDK bundle (only user-facing client-/lib- packages;
92
+ // @aws-sdk/core, @smithy/*, credential-provider-* are SDK internals covered
93
+ // by the bundle, not something the user bundles-in) ---
94
+ const clients = graph.awsSdkClients.filter((c) => /^@aws-sdk\/(client|lib)-/.test(c))
95
+ const sdk = kb.resolveSdkBundle(clients, version)
96
+ v.sdkBundle = sdk.bundle
97
+ v.bundleIn = sdk.bundleIn
98
+ for (const c of sdk.bundleIn) {
99
+ v.warnings.push({ kind: 'sdk-bundle', note: `${c} is not in the LLRT ${sdk.bundle} bundle`, fix: `bundle ${c} INTO the artifact (do not mark it external), or use the -full-sdk bundle.` })
100
+ }
101
+
102
+ // --- (sci-xwk.10) known-bad npm + workload heuristic ---
103
+ const knownBad = kb.loadKnownBad().packages
104
+ for (const p of graph.npmPkgs) {
105
+ const bad = knownBad[p.name]
106
+ if (!bad) continue
107
+ if (bad.status === 'blocker') v.blockers.push({ api: p.name, file: p.file, fatal: true, fix: bad.reason })
108
+ else v.warnings.push({ kind: 'known-bad', file: p.file, note: `${p.name}: ${bad.reason}`, fix: bad.reason })
109
+ }
110
+ for (const w of workloadHeuristic(firstParty, projectDir)) v.warnings.push(w)
111
+
112
+ // --- (sci-xwk.12) staticTier ---
113
+ const hasFatal = v.blockers.some((b) => b.fatal)
114
+ const hasPartial = v.warnings.some((w) => w.kind === 'partial-api')
115
+ v.staticTier = hasFatal ? 'likely-incompatible' : hasPartial ? 'unknown' : 'likely-compatible'
116
+ v.recommendation = hasFatal ? 'incompatible' : 'unknown'
117
+ v.valid = !hasFatal
118
+ return v
119
+ }
120
+
121
+ /** Match disallowed patterns across the given (reachable, first-party) files. */
122
+ function disallowedApiScan(files, projectDir) {
123
+ const hits = []
124
+ for (const file of files) {
125
+ const text = readSafe(file)
126
+ if (text == null) continue
127
+ for (const d of DISALLOWED) {
128
+ if (d.re.test(text)) hits.push({ api: d.api, file: relOf(projectDir, file), fix: d.fix })
129
+ }
130
+ }
131
+ return hits
132
+ }
133
+
134
+ /**
135
+ * Match confirmed behavioral quirks (from the versioned known-quirks DB) across
136
+ * reachable first-party files. Each signature is a source regex; a hit carries
137
+ * the root cause + fix. Signatures live in data/known-quirks.<version>.json so
138
+ * findings accrete without code changes.
139
+ */
140
+ function knownQuirksScan(files, projectDir, version) {
141
+ const { quirks } = kb.loadKnownQuirks(version)
142
+ if (!quirks || !quirks.length) return []
143
+ const compiled = quirks
144
+ .map((q) => {
145
+ try {
146
+ return { ...q, re: new RegExp(q.signature) }
147
+ } catch (_) {
148
+ return null
149
+ }
150
+ })
151
+ .filter(Boolean)
152
+ const hits = []
153
+ const seen = new Set()
154
+ for (const file of files) {
155
+ const text = readSafe(file)
156
+ if (text == null) continue
157
+ for (const q of compiled) {
158
+ if (seen.has(q.id)) continue
159
+ if (q.re.test(text)) {
160
+ seen.add(q.id)
161
+ hits.push({ id: q.id, api: q.api, severity: q.severity || 'warning', note: q.note, fix: q.fix, file: relOf(projectDir, file) })
162
+ }
163
+ }
164
+ }
165
+ return hits
166
+ }
167
+
168
+ /** Very light compute-heavy heuristic: nested loops in a reachable file. */
169
+ function workloadHeuristic(files, projectDir) {
170
+ const out = []
171
+ for (const file of files) {
172
+ const text = readSafe(file)
173
+ if (text == null) continue
174
+ const nested = /for\s*\([^)]*\)\s*\{[\s\S]{0,400}?for\s*\(/.test(text) || /while\s*\([^)]*\)\s*\{[\s\S]{0,400}?for\s*\(/.test(text)
175
+ if (nested) {
176
+ /** @type {import('./verdict').Warning} */
177
+ const w = { kind: 'workload', file: relOf(projectDir, file), note: 'nested loops detected — LLRT has no JIT and may be SLOWER for CPU-bound work here', fix: 'compatible, but measure before switching; consider keeping compute-heavy functions on Node.' }
178
+ out.push(w)
179
+ }
180
+ }
181
+ return out
182
+ }
183
+
184
+ function readSafe(file) {
185
+ try {
186
+ return fs.readFileSync(file, 'utf8')
187
+ } catch (_) {
188
+ return null
189
+ }
190
+ }
191
+ function relOf(projectDir, file) {
192
+ return projectDir ? path.relative(projectDir, file) : file
193
+ }
194
+
195
+ module.exports = { staticScan, disallowedApiScan, workloadHeuristic, knownQuirksScan }
package/src/verdict.js ADDED
@@ -0,0 +1,133 @@
1
+ // @ts-check
2
+ 'use strict'
3
+ /**
4
+ * Verdict contract (bead sci-xwk.2). The stable shape every tier + both
5
+ * consumers (serverless plugin, smart-ci validator) depend on. See spec §3.
6
+ */
7
+
8
+ /**
9
+ * @typedef {Object} Blocker
10
+ * @property {string} api Offending API/module (e.g. 'node:http')
11
+ * @property {string} [file] Where it was found
12
+ * @property {string} [via] Dependency chain (e.g. 'undici')
13
+ * @property {boolean} fatal
14
+ * @property {string} fix Concrete remedy
15
+ */
16
+ /**
17
+ * @typedef {Object} Warning
18
+ * @property {'workload'|'partial-api'|'sdk-bundle'|'known-bad'} kind
19
+ * @property {string} [file]
20
+ * @property {string} note
21
+ * @property {string} [fix]
22
+ */
23
+ /**
24
+ * @typedef {Object} CallDiff
25
+ * @property {string} event
26
+ * @property {string} path
27
+ * @property {*} node
28
+ * @property {*} llrt
29
+ */
30
+ /**
31
+ * @typedef {Object} Provenance
32
+ * @property {string} tier highest tier that ran: 'static'|'local'|'deploy'
33
+ * @property {string} [hostPlatform] where the local run executed (e.g. 'darwin')
34
+ * @property {string} [hostArch] e.g. 'arm64'
35
+ * @property {string} [targetPlatform] Lambda target (always 'linux')
36
+ * @property {string} [targetArch] Lambda target arch (default 'arm64')
37
+ * @property {boolean} [hostMatchesTarget]
38
+ * @property {number} [corpusSize] number of events the local tier ran
39
+ * @property {boolean} [cached] verdict served from the persist cache
40
+ */
41
+ /**
42
+ * @typedef {Object} FunctionVerdict
43
+ * @property {string} fn
44
+ * @property {'switch'|'incompatible'|'compatible-but-not-recommended'|'unknown'} recommendation
45
+ * @property {'high'|'medium'|'low'} confidence how much to trust this verdict
46
+ * @property {boolean} valid
47
+ * @property {'likely-compatible'|'likely-incompatible'|'unknown'} staticTier
48
+ * @property {boolean} ranLocal
49
+ * @property {boolean} ranDeploy
50
+ * @property {'no-sdk'|'std-sdk'|'full-sdk'} sdkBundle
51
+ * @property {string[]} bundleIn
52
+ * @property {{ node: number|null, llrt: number|null, ratio: number|null, measured: boolean }} coldStartDeltaMs
53
+ * @property {Provenance} provenance
54
+ * @property {Blocker[]} blockers
55
+ * @property {Warning[]} warnings
56
+ * @property {CallDiff[]} diffs
57
+ * @property {string} llrtVersion
58
+ * @property {string[]} verified
59
+ */
60
+
61
+ const { ORG_PINNED_LLRT_VERSION } = require('./knowledge-base')
62
+
63
+ /**
64
+ * @param {string} fn
65
+ * @param {Partial<FunctionVerdict>} [over]
66
+ * @returns {FunctionVerdict}
67
+ */
68
+ function emptyVerdict(fn, over = {}) {
69
+ return {
70
+ fn,
71
+ recommendation: 'unknown',
72
+ confidence: 'low',
73
+ valid: true,
74
+ staticTier: 'unknown',
75
+ ranLocal: false,
76
+ ranDeploy: false,
77
+ sdkBundle: 'no-sdk',
78
+ bundleIn: [],
79
+ coldStartDeltaMs: { node: null, llrt: null, ratio: null, measured: false },
80
+ provenance: { tier: 'static' },
81
+ blockers: [],
82
+ warnings: [],
83
+ diffs: [],
84
+ llrtVersion: ORG_PINNED_LLRT_VERSION,
85
+ verified: [],
86
+ ...over,
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Decide the recommendation from the accumulated evidence (bead sci-xwk.25).
92
+ * Empirical (local run) is the source of truth: statically-ambiguous functions
93
+ * are decided by actually running them.
94
+ * @param {FunctionVerdict} v
95
+ * @returns {FunctionVerdict}
96
+ */
97
+ function finalizeRecommendation(v) {
98
+ const hasFatal = v.blockers.some((b) => b.fatal)
99
+ const hasWorkloadWarn = v.warnings.some((w) => w.kind === 'workload')
100
+ const localClean = v.ranLocal && v.diffs.length === 0 && !v.blockers.some((b) => b.fatal)
101
+
102
+ /** @type {FunctionVerdict['recommendation']} */
103
+ let rec = 'unknown'
104
+ if (hasFatal) rec = 'incompatible'
105
+ else if (localClean) rec = hasWorkloadWarn ? 'compatible-but-not-recommended' : 'switch'
106
+ else if (v.ranLocal && v.diffs.length > 0) rec = 'incompatible'
107
+ else if (v.staticTier === 'likely-compatible') rec = hasWorkloadWarn ? 'compatible-but-not-recommended' : 'unknown'
108
+ else rec = 'unknown'
109
+
110
+ v.recommendation = rec
111
+ v.valid = !hasFatal
112
+ v.confidence = computeConfidence(v)
113
+ return v
114
+ }
115
+
116
+ /**
117
+ * How much to trust the verdict.
118
+ * - static only → 'low' (we did not actually run it)
119
+ * - ran local, host≠target → 'medium' (verified on the host arch/OS, not Lambda's linux)
120
+ * - ran local, host=target → 'high'
121
+ * A cached verdict keeps whatever confidence it was stored with.
122
+ * @param {FunctionVerdict} v
123
+ * @returns {FunctionVerdict['confidence']}
124
+ */
125
+ function computeConfidence(v) {
126
+ if (v.provenance && v.provenance.cached) return v.confidence || 'medium'
127
+ if (v.ranDeploy) return 'high'
128
+ if (!v.ranLocal) return 'low'
129
+ const p = /** @type {Provenance} */ (v.provenance || {})
130
+ return p.hostMatchesTarget === false ? 'medium' : 'high'
131
+ }
132
+
133
+ module.exports = { emptyVerdict, finalizeRecommendation }