@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,167 @@
1
+ // @ts-check
2
+ 'use strict'
3
+ /**
4
+ * Import-graph resolver (bead sci-xwk.6).
5
+ *
6
+ * Resolve a function handler's transitive runtime imports so the static scan
7
+ * can see node: builtins, npm packages, and @aws-sdk clients — INCLUDING those
8
+ * pulled in transitively (a lib like undici/axios can import node:http even when
9
+ * the service only calls fetch). First-party files are followed fully; npm
10
+ * packages are scanned to a bounded depth for the node: builtins they use, so a
11
+ * transitive blocker is attributed with its `via` chain.
12
+ */
13
+ const fs = require('fs')
14
+ const path = require('path')
15
+ const nodeModule = require('module')
16
+ const precinct = require('precinct')
17
+
18
+ const debug = safeDebug('llrt:graph')
19
+
20
+ const IS_BUILTIN =
21
+ typeof nodeModule.isBuiltin === 'function'
22
+ ? (n) => nodeModule.isBuiltin(n)
23
+ : ((set) => (n) => set.has(String(n).replace(/^node:/, '')))(new Set(nodeModule.builtinModules))
24
+
25
+ /**
26
+ * @param {string} entryFile absolute path to the handler entry
27
+ * @param {{ projectDir?: string, maxNpmDepth?: number, maxFiles?: number }} [opts]
28
+ * @returns {{ builtins: {name:string,file:string,via:string}[], npmPkgs: {name:string,file:string}[], awsSdkClients: string[], firstPartyFiles: string[], scannedFiles: number, errors: string[] }}
29
+ */
30
+ function resolveImportGraph(entryFile, opts = {}) {
31
+ const maxNpmDepth = opts.maxNpmDepth == null ? 2 : opts.maxNpmDepth
32
+ const maxFiles = opts.maxFiles == null ? 4000 : opts.maxFiles
33
+ const builtins = /** @type {{name:string,file:string,via:string}[]} */ ([])
34
+ const npmSet = new Map() // pkg -> firstFile
35
+ const awsSdk = new Set()
36
+ const errors = []
37
+ const visited = new Set()
38
+ const firstParty = new Set()
39
+ let scanned = 0
40
+
41
+ /** @param {string} file @param {number} npmDepth @param {string} via @param {boolean} isFirstParty */
42
+ function walk(file, npmDepth, via, isFirstParty) {
43
+ if (!file || visited.has(file) || scanned >= maxFiles) return
44
+ visited.add(file)
45
+ if (isFirstParty) firstParty.add(file)
46
+ let deps
47
+ try {
48
+ deps = precinct.paperwork(file, { includeCore: true })
49
+ } catch (err) {
50
+ errors.push(`parse ${rel(opts.projectDir, file)}: ${err.message}`)
51
+ return
52
+ }
53
+ scanned += 1
54
+ for (const dep of deps) {
55
+ if (IS_BUILTIN(dep)) {
56
+ builtins.push({ name: normalizeBuiltin(dep), file: rel(opts.projectDir, file), via })
57
+ continue
58
+ }
59
+ if (dep.startsWith('.') || path.isAbsolute(dep)) {
60
+ // First-party relative import — always follow, stays first-party.
61
+ const resolved = resolveLocal(dep, file)
62
+ if (resolved) walk(resolved, npmDepth, via, isFirstParty)
63
+ else errors.push(`unresolved ${dep} from ${rel(opts.projectDir, file)}`)
64
+ continue
65
+ }
66
+ // Bare specifier = npm package (or scoped). Record it.
67
+ const pkg = packageName(dep)
68
+ const isAwsSdk = pkg.startsWith('@aws-sdk/') || pkg.startsWith('@smithy/') || pkg.startsWith('@aws-crypto/')
69
+ if (isAwsSdk) awsSdk.add(pkg)
70
+ if (!npmSet.has(pkg)) npmSet.set(pkg, rel(opts.projectDir, file))
71
+ // Do NOT traverse AWS SDK / smithy internals: LLRT pre-bundles them and
72
+ // routes them through fetch, so their internal node:http usage is a false
73
+ // positive and their sub-packages are noise. resolveSdkBundle handles
74
+ // bundle membership at the client level instead.
75
+ if (isAwsSdk) continue
76
+ // Bounded transitive scan for node: builtins the package uses.
77
+ if (npmDepth < maxNpmDepth) {
78
+ const entry = resolvePackageEntry(pkg, file)
79
+ // npm internals are NOT first-party (their own disallowed patterns
80
+ // shouldn't be attributed to the service source).
81
+ if (entry) walk(entry, npmDepth + 1, via === '(direct)' ? pkg : via, false)
82
+ }
83
+ }
84
+ }
85
+
86
+ walk(entryFile, 0, '(direct)', true)
87
+
88
+ return {
89
+ builtins: dedupeBuiltins(builtins),
90
+ npmPkgs: [...npmSet.entries()].map(([name, file]) => ({ name, file })),
91
+ awsSdkClients: [...awsSdk],
92
+ firstPartyFiles: [...firstParty],
93
+ scannedFiles: scanned,
94
+ errors,
95
+ }
96
+ }
97
+
98
+ function normalizeBuiltin(n) {
99
+ return String(n).replace(/^node:/, '')
100
+ }
101
+ function dedupeBuiltins(list) {
102
+ const seen = new Map()
103
+ for (const b of list) {
104
+ const key = `${b.name}|${b.via}`
105
+ if (!seen.has(key)) seen.set(key, b)
106
+ }
107
+ return [...seen.values()]
108
+ }
109
+ function packageName(spec) {
110
+ if (spec.startsWith('@')) {
111
+ const [scope, name] = spec.split('/')
112
+ return name ? `${scope}/${name}` : scope
113
+ }
114
+ return spec.split('/')[0]
115
+ }
116
+ const SOURCE_EXTS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs', '.jsx']
117
+ function resolveLocal(dep, fromFile) {
118
+ const base = path.resolve(path.dirname(fromFile), dep)
119
+ const candidates = [base]
120
+ // TypeScript ESM writes imports with a .js/.mjs/.cjs extension even though the
121
+ // source is .ts/.mts/.cts — remap the extension so we resolve the real file.
122
+ const ext = path.extname(base)
123
+ if (['.js', '.mjs', '.cjs'].includes(ext)) {
124
+ const noExt = base.slice(0, -ext.length)
125
+ for (const e of SOURCE_EXTS) candidates.push(noExt + e)
126
+ } else {
127
+ for (const e of SOURCE_EXTS) candidates.push(base + e)
128
+ }
129
+ // index files (directory imports)
130
+ for (const e of SOURCE_EXTS) candidates.push(path.join(base, `index${e}`))
131
+ for (const c of candidates) {
132
+ try {
133
+ if (fs.statSync(c).isFile()) return c
134
+ } catch (_) {
135
+ /* ignore */
136
+ }
137
+ }
138
+ return null
139
+ }
140
+ function resolvePackageEntry(pkg, fromFile) {
141
+ try {
142
+ // Resolve the package.json to find its main/module without executing it.
143
+ const pkgJsonPath = require.resolve(`${pkg}/package.json`, { paths: [path.dirname(fromFile)] })
144
+ // eslint-disable-next-line import/no-dynamic-require, global-require
145
+ const meta = require(pkgJsonPath)
146
+ const rel2 = meta.module || meta.main || 'index.js'
147
+ const entry = path.join(path.dirname(pkgJsonPath), rel2)
148
+ if (fs.existsSync(entry) && fs.statSync(entry).isFile()) return entry
149
+ const idx = path.join(path.dirname(pkgJsonPath), 'index.js')
150
+ return fs.existsSync(idx) ? idx : null
151
+ } catch (_) {
152
+ return null
153
+ }
154
+ }
155
+ function rel(projectDir, file) {
156
+ return projectDir ? path.relative(projectDir, file) : file
157
+ }
158
+ function safeDebug(ns) {
159
+ try {
160
+ // eslint-disable-next-line global-require
161
+ return require('@davidwells/smart-log/debug')(ns)
162
+ } catch (_) {
163
+ return () => {}
164
+ }
165
+ }
166
+
167
+ module.exports = { resolveImportGraph }
package/src/index.js ADDED
@@ -0,0 +1,86 @@
1
+ // @ts-check
2
+ 'use strict'
3
+ /**
4
+ * llrt-analyzer — public API (bead sci-xwk.2).
5
+ *
6
+ * Decide, per Lambda function, whether it can run on AWS LLRT (QuickJS) for
7
+ * ~10x faster cold starts, and help flip it. Tier 1 (static) works with no LLRT
8
+ * binary; Tiers 2/3 (local call-comparison, opt-in deploy-verify) are layered on
9
+ * in later beads. See docs/plans/llrt-analyzer.md.
10
+ */
11
+ const kb = require('./knowledge-base')
12
+ const { staticScan } = require('./static-scan')
13
+ const { resolveImportGraph } = require('./import-graph')
14
+ const { emptyVerdict, finalizeRecommendation } = require('./verdict')
15
+ const { discoverFromServerless, writeResultJson } = require('./service')
16
+
17
+ /**
18
+ * Tier-2 hook. sci-xwk.19 (localCallCompare) registers a runner here so the
19
+ * static tier stays usable with no LLRT binary. Signature:
20
+ * (verdict, input) => Promise<FunctionVerdict>.
21
+ * @type {null | ((v: import('./verdict').FunctionVerdict, input: any) => Promise<import('./verdict').FunctionVerdict>)}
22
+ */
23
+ let localCallCompareHook = null
24
+ /** @param {typeof localCallCompareHook} fn */
25
+ function registerLocalCallCompare(fn) {
26
+ localCallCompareHook = fn
27
+ }
28
+
29
+ /**
30
+ * Analyze one function entrypoint through the requested tiers.
31
+ * v0.1: static tier only. localCallCompare()/deployVerify() land in sci-xwk.19+.
32
+ * @param {{ entryPoint: string, projectDir?: string, fn?: string, llrtVersion?: string, tiers?: string[], corpus?: any[], handlerExport?: string, cache?: boolean, cacheFile?: string, targetArch?: string }} input
33
+ * @returns {Promise<import('./verdict').FunctionVerdict>}
34
+ */
35
+ async function analyzeFunction(input) {
36
+ const tiers = input.tiers || ['static']
37
+ let verdict = staticScan(input)
38
+ if (tiers.includes('local') && localCallCompareHook) {
39
+ verdict = await localCallCompareHook(verdict, input)
40
+ }
41
+ return finalizeRecommendation(verdict)
42
+ }
43
+
44
+ /**
45
+ * Analyze every function in a service — discovered from serverless.yml or passed
46
+ * explicitly (bead sci-xwk.28). Writes result.json when `resultFile` is given.
47
+ * @param {{ projectDir: string, functions?: {fn:string, entryPoint:string, handlerExport?:string}[], tiers?: string[], llrtVersion?: string, resultFile?: string, cache?: boolean, cacheFile?: string, targetArch?: string }} input
48
+ * @returns {Promise<import('./verdict').FunctionVerdict[]>}
49
+ */
50
+ async function analyzeService(input) {
51
+ const fns = input.functions && input.functions.length ? input.functions : discoverFromServerless(input.projectDir)
52
+ const out = []
53
+ for (const f of fns) {
54
+ // eslint-disable-next-line no-await-in-loop
55
+ out.push(
56
+ await analyzeFunction({
57
+ ...f,
58
+ projectDir: input.projectDir,
59
+ tiers: input.tiers,
60
+ llrtVersion: input.llrtVersion,
61
+ cache: input.cache,
62
+ cacheFile: input.cacheFile,
63
+ targetArch: input.targetArch,
64
+ }),
65
+ )
66
+ }
67
+ if (input.resultFile) writeResultJson(out, input.resultFile)
68
+ return out
69
+ }
70
+
71
+ // Register the Tier-2 runner so `tiers:['static','local']` works out of the box.
72
+ // eslint-disable-next-line global-require
73
+ registerLocalCallCompare(require('./local-compare').localCallCompare)
74
+
75
+ module.exports = {
76
+ analyzeFunction,
77
+ analyzeService,
78
+ registerLocalCallCompare,
79
+ staticScan,
80
+ resolveImportGraph,
81
+ discoverFromServerless,
82
+ writeResultJson,
83
+ emptyVerdict,
84
+ finalizeRecommendation,
85
+ ...kb,
86
+ }
@@ -0,0 +1,139 @@
1
+ // @ts-check
2
+ 'use strict'
3
+ /**
4
+ * Tier-0 knowledge base (bead sci-xwk.3).
5
+ *
6
+ * Versioned, org-pinned compat data: node-builtin support matrix, AWS-SDK
7
+ * bundle membership, and known-bad npm packages. Everything downstream cross-
8
+ * references this. Pinning ONE org-wide LLRT version keeps verdicts
9
+ * reproducible; `scripts/refresh.js` (sci-xwk.4) regenerates the data files.
10
+ */
11
+ const path = require('path')
12
+
13
+ /** The single org-wide pinned LLRT version. Bump deliberately (re-verifies all). */
14
+ const ORG_PINNED_LLRT_VERSION = '0.8.1-beta'
15
+
16
+ const DATA_DIR = path.join(__dirname, '..', 'data')
17
+
18
+ /** @typedef {'supported'|'partial'|'planned'|'unsupported'|'unknown'} BuiltinStatus */
19
+
20
+ /** @param {string} [version] */
21
+ function loadCompatTable(version = ORG_PINNED_LLRT_VERSION) {
22
+ return requireData(`compat-table.${version}.json`, version)
23
+ }
24
+ /** @param {string} [version] */
25
+ function loadSdkBundle(version = ORG_PINNED_LLRT_VERSION) {
26
+ return requireData(`sdk-bundle.${version}.json`, version)
27
+ }
28
+ function loadKnownBad() {
29
+ // eslint-disable-next-line global-require
30
+ return require(path.join(DATA_DIR, 'known-bad.json'))
31
+ }
32
+
33
+ /**
34
+ * Confirmed behavioral quirks (source-signature → root cause + fix) that the
35
+ * static tier and deploy guard consult so behavioral breaks (e.g. hono/cors
36
+ * emptying the body under LLRT) are caught without a local run. Optional file —
37
+ * absence is not an error (returns an empty set).
38
+ * @param {string} [version]
39
+ * @returns {{ quirks: {id:string, signature:string, files?:string, severity:'blocker'|'warning', api:string, note:string, fix:string, tracking?:string}[] }}
40
+ */
41
+ function loadKnownQuirks(version = ORG_PINNED_LLRT_VERSION) {
42
+ try {
43
+ // eslint-disable-next-line global-require, import/no-dynamic-require
44
+ return require(path.join(DATA_DIR, `known-quirks.${version}.json`))
45
+ } catch (_) {
46
+ return { quirks: [] }
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Pinned sha256 checksums of the extracted llrt binary per release asset, used
52
+ * to verify downloads. Optional — absence means "verify nothing, warn".
53
+ * @param {string} [version]
54
+ * @returns {{ algorithm?: string, binaries: Record<string,string> }}
55
+ */
56
+ function loadBinaryChecksums(version = ORG_PINNED_LLRT_VERSION) {
57
+ try {
58
+ // eslint-disable-next-line global-require, import/no-dynamic-require
59
+ return require(path.join(DATA_DIR, `llrt-binary-checksums.${version}.json`))
60
+ } catch (_) {
61
+ return { binaries: {} }
62
+ }
63
+ }
64
+
65
+ function requireData(file, version) {
66
+ try {
67
+ // eslint-disable-next-line global-require, import/no-dynamic-require
68
+ return require(path.join(DATA_DIR, file))
69
+ } catch (err) {
70
+ throw new Error(
71
+ `llrt-analyzer: no knowledge-base data for LLRT version "${version}" (${file}). ` +
72
+ `Run scripts/refresh.js to generate it, or use the pinned version ${ORG_PINNED_LLRT_VERSION}.`,
73
+ )
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Status of a node: builtin under LLRT. Accepts 'node:crypto' or 'crypto'.
79
+ * @param {string} name
80
+ * @param {string} [version]
81
+ * @returns {BuiltinStatus}
82
+ */
83
+ function builtinStatus(name, version = ORG_PINNED_LLRT_VERSION) {
84
+ const table = loadCompatTable(version)
85
+ const key = String(name).replace(/^node:/, '')
86
+ const status = table.builtins[key]
87
+ return /** @type {BuiltinStatus} */ (status || 'unknown')
88
+ }
89
+
90
+ /** @param {string} name @param {string} [version] */
91
+ function builtinNote(name, version = ORG_PINNED_LLRT_VERSION) {
92
+ const table = loadCompatTable(version)
93
+ const key = String(name).replace(/^node:/, '')
94
+ return (table.notes && table.notes[key]) || ''
95
+ }
96
+
97
+ /**
98
+ * Resolve which LLRT SDK bundle a set of @aws-sdk packages needs, and which
99
+ * clients must be bundled INTO the artifact (absent from the chosen bundle).
100
+ * (bead sci-xwk.9 uses this.)
101
+ * @param {string[]} clients e.g. ['@aws-sdk/client-dynamodb','@aws-sdk/lib-dynamodb']
102
+ * @param {string} [version]
103
+ * @returns {{ bundle: 'no-sdk'|'std-sdk'|'full-sdk', bundleIn: string[] }}
104
+ */
105
+ function resolveSdkBundle(clients, version = ORG_PINNED_LLRT_VERSION) {
106
+ const map = loadSdkBundle(version)
107
+ const uniq = [...new Set((clients || []).filter(Boolean))]
108
+ if (uniq.length === 0) return { bundle: 'no-sdk', bundleIn: [] }
109
+
110
+ const inAlways = (c) => map.always.some((a) => c === a || c.startsWith(a))
111
+ const inStd = (c) => map.std.includes(c)
112
+ const inFull = (c) => map.full.includes(c)
113
+
114
+ let needFull = false
115
+ const bundleIn = []
116
+ for (const c of uniq) {
117
+ if (inAlways(c) || inStd(c)) continue
118
+ if (inFull(c)) {
119
+ needFull = true
120
+ continue
121
+ }
122
+ // Not in any known bundle -> must be bundled into the artifact.
123
+ bundleIn.push(c)
124
+ }
125
+ const bundle = needFull ? 'full-sdk' : 'std-sdk'
126
+ return { bundle, bundleIn }
127
+ }
128
+
129
+ module.exports = {
130
+ ORG_PINNED_LLRT_VERSION,
131
+ loadCompatTable,
132
+ loadSdkBundle,
133
+ loadKnownBad,
134
+ loadKnownQuirks,
135
+ loadBinaryChecksums,
136
+ builtinStatus,
137
+ builtinNote,
138
+ resolveSdkBundle,
139
+ }
@@ -0,0 +1,156 @@
1
+ // @ts-check
2
+ 'use strict'
3
+ /**
4
+ * Tier-2 local call-comparison (beads sci-xwk.19 + .22). Bundle the handler for
5
+ * LLRT, then for each corpus event run it under BOTH Node and the LLRT binary
6
+ * with downstream calls intercepted (fetch + AWS SDK recorder), and diff the
7
+ * behavior. This is the decisive tier: empirical, no deploy, no side effects.
8
+ *
9
+ * Also stamps provenance + confidence (host arch vs Lambda's linux target),
10
+ * measures the cold-start win, and caches the (expensive) result keyed on the
11
+ * built-bundle content so repeat runs are instant on unchanged inputs.
12
+ */
13
+ const path = require('path')
14
+ const { buildForLlrt } = require('./build-for-llrt')
15
+ const { getLlrtBinary, isCached, hostTarget } = require('./binary-manager')
16
+ const { runUnder } = require('./run-under')
17
+ const { diffRun } = require('./diff')
18
+ const { attributeError } = require('./error-map')
19
+ const { loadCorpus, synthApiGwV2 } = require('./corpus')
20
+ const { measureColdStart } = require('./cold-start')
21
+ const { finalizeRecommendation } = require('./verdict')
22
+ const persist = require('./persist')
23
+
24
+ /**
25
+ * localCallCompare hook — enriches a static verdict with Tier-2 evidence.
26
+ * @param {import('./verdict').FunctionVerdict} verdict
27
+ * @param {{ entryPoint: string, projectDir?: string, corpus?: any[], handlerExport?: string, llrtVersion?: string, targetArch?: string, cache?: boolean, cacheFile?: string, measureColdStart?: boolean }} input
28
+ * @returns {Promise<import('./verdict').FunctionVerdict>}
29
+ */
30
+ async function localCallCompare(verdict, input) {
31
+ const sdk = verdict.sdkBundle === 'full-sdk' ? 'full-sdk' : verdict.sdkBundle === 'no-sdk' ? 'no-sdk' : 'std-sdk'
32
+ const version = input.llrtVersion || verdict.llrtVersion
33
+
34
+ // Provenance: we run on the HOST arch/OS, but Lambda runs linux. Record the
35
+ // (mis)match so confidence reflects it — a darwin "compatible" is not a linux
36
+ // guarantee (this whole tool exists because host≠target can bite).
37
+ const host = hostTarget()
38
+ const target = { platform: 'linux', arch: input.targetArch || 'arm64' }
39
+ const hostMatchesTarget = host.platform === target.platform && host.arch === target.arch
40
+ verdict.provenance = {
41
+ tier: 'local',
42
+ hostPlatform: host.platform,
43
+ hostArch: host.arch,
44
+ targetPlatform: target.platform,
45
+ targetArch: target.arch,
46
+ hostMatchesTarget,
47
+ }
48
+ if (!hostMatchesTarget) {
49
+ verdict.warnings.push({
50
+ kind: 'partial-api',
51
+ note: `verified on ${host.platform}/${host.arch}, but Lambda runs ${target.platform}/${target.arch} — behavior could differ; confidence lowered to medium`,
52
+ fix: 'for full rigor run the linux LLRT binary in a container, or use the opt-in deploy-verify tier.',
53
+ })
54
+ }
55
+
56
+ // 1. Bundle for LLRT (aws-sdk aliased to the recorder — no side effects).
57
+ let build
58
+ try {
59
+ build = await buildForLlrt({ entryPoint: input.entryPoint, handlerExport: input.handlerExport })
60
+ } catch (err) {
61
+ verdict.warnings.push({ kind: 'partial-api', note: `could not bundle for LLRT: ${err.message}`, fix: 'ensure esbuild can bundle the handler (TS transpile, resolvable imports).' })
62
+ return verdict
63
+ }
64
+ if (build.errors.length) {
65
+ verdict.blockers.push({ api: 'bundle', fatal: true, fix: `esbuild failed: ${build.errors[0]}` })
66
+ return verdict
67
+ }
68
+
69
+ // 2. Cache: content-based key on the built bundle. A hit replays the expensive
70
+ // verdict (the node+llrt runs) verbatim — only the bundle changing invalidates.
71
+ const cacheEnabled = input.cache !== false
72
+ const storeFile = input.cacheFile || persist.DEFAULT_STORE
73
+ const lockfile = persist.findLockfile(input.projectDir || path.dirname(input.entryPoint))
74
+ const key = persist.bundleKey({ bundleFile: build.outFile, lockfile, llrtVersion: version, fn: verdict.fn })
75
+ if (cacheEnabled) {
76
+ const hit = persist.getCached(storeFile, key)
77
+ if (hit) {
78
+ hit.fn = verdict.fn
79
+ hit.provenance = { tier: 'local', ...(hit.provenance || {}), cached: true }
80
+ return hit
81
+ }
82
+ }
83
+
84
+ // 3. LLRT binary (skip Tier 2 gracefully when offline + not cached).
85
+ let bin
86
+ try {
87
+ if (!isCached({ version, sdk })) {
88
+ // getLlrtBinary will download; allow it, but surface a clean skip on failure.
89
+ }
90
+ bin = await getLlrtBinary({ version, sdk })
91
+ } catch (err) {
92
+ verdict.warnings.push({ kind: 'partial-api', note: `Tier 2 skipped — LLRT binary unavailable: ${err.message}`, fix: 'run online once to cache the LLRT binary, then re-run for a definitive verdict.' })
93
+ return verdict
94
+ }
95
+
96
+ // 4. Corpus: fixtures-first (sci-xwk.23), else synthetic per adapter.
97
+ const corpus = loadCorpus({ projectDir: input.projectDir || '.', fnName: verdict.fn, explicit: input.corpus })
98
+
99
+ verdict.ranLocal = true
100
+ verdict.provenance.corpusSize = corpus.length
101
+ const attributed = new Set()
102
+ for (let i = 0; i < corpus.length; i += 1) {
103
+ const event = corpus[i]
104
+ const label = event && event.routeKey ? event.routeKey : `event#${i}`
105
+ // eslint-disable-next-line no-await-in-loop
106
+ const nodeRun = runUnder({ runtime: 'node', bundleFile: build.outFile, event, resultMarker: build.resultMarker })
107
+ // eslint-disable-next-line no-await-in-loop
108
+ const llrtRun = runUnder({ runtime: 'llrt', bin, bundleFile: build.outFile, event, resultMarker: build.resultMarker })
109
+
110
+ if (!llrtRun.ok) {
111
+ const at = attributeError(`${llrtRun.error || ''} ${llrtRun.stack || ''} ${llrtRun.stderr || ''}`)
112
+ const api = at ? at.api : 'llrt-runtime'
113
+ if (!attributed.has(api) && !verdict.blockers.some((b) => b.api === api)) {
114
+ attributed.add(api)
115
+ const detail = (llrtRun.stderr || llrtRun.error || '').trim().slice(0, 300)
116
+ verdict.blockers.push({ api, fatal: true, fix: at ? at.fix : `LLRT run failed for ${label}: ${detail}` })
117
+ }
118
+ }
119
+ for (const d of diffRun(label, nodeRun, llrtRun)) verdict.diffs.push(d)
120
+ }
121
+
122
+ // 5. Cold-start win — the number that justifies the switch (idea #3).
123
+ if (input.measureColdStart !== false && !verdict.blockers.some((b) => b.fatal)) {
124
+ try {
125
+ verdict.coldStartDeltaMs = measureColdStart({ llrtBin: bin, bundleFile: build.outFile })
126
+ } catch (_) {
127
+ /* non-fatal — leave unmeasured */
128
+ }
129
+ }
130
+
131
+ // 6. Promote confirmed partial APIs to verified when the runs are clean.
132
+ if (!verdict.blockers.some((b) => b.fatal) && verdict.diffs.length === 0) {
133
+ const promoted = []
134
+ verdict.warnings = verdict.warnings.filter((w) => {
135
+ if (w.kind === 'partial-api' && /node:/.test(w.note)) {
136
+ const m = w.note.match(/node:([a-z0-9_/]+)/i)
137
+ if (m) promoted.push(`node:${m[1]} (used calls behaved identically)`)
138
+ return false
139
+ }
140
+ return true
141
+ })
142
+ verdict.verified.push(...promoted)
143
+ }
144
+
145
+ // 7. Persist the finalized verdict so the next run (same bundle) is instant.
146
+ if (cacheEnabled) {
147
+ try {
148
+ persist.saveVerdict(storeFile, key, finalizeRecommendation(verdict))
149
+ } catch (_) {
150
+ /* cache is best-effort */
151
+ }
152
+ }
153
+ return verdict
154
+ }
155
+
156
+ module.exports = { localCallCompare, synthApiGwV2 }
package/src/persist.js ADDED
@@ -0,0 +1,110 @@
1
+ // @ts-check
2
+ 'use strict'
3
+ /**
4
+ * "llrtable" verdict persistence + cache invalidation (bead sci-xwk.35).
5
+ *
6
+ * The expensive local/deploy tiers are cached so we don't re-verify every CI
7
+ * run. The verdict is keyed on hash(bundle-inputs + lockfile + llrtVersion): a
8
+ * dependency/bundle/LLRT-version change invalidates it. NOTE: the cheap Tier-1
9
+ * disallowed-API scan is expected to run on EVERY CI run regardless of this
10
+ * cache (a code-only change can introduce an unsupported API) — see static-scan.
11
+ *
12
+ * In smart-ci this maps onto manifest-core storage/hashing; here we provide a
13
+ * portable JSON-file store so the analyzer is usable standalone.
14
+ */
15
+ const fs = require('fs')
16
+ const os = require('os')
17
+ const path = require('path')
18
+ const crypto = require('crypto')
19
+
20
+ /** Default cache store (shared with the binary cache root). */
21
+ const DEFAULT_STORE = path.join(os.homedir(), '.smart-ci', 'llrt', 'verdict-cache.json')
22
+
23
+ /**
24
+ * Compute the cache key for a function's llrtable verdict.
25
+ * @param {{ files: string[], lockfile?: string, llrtVersion: string }} input
26
+ */
27
+ function llrtableKey(input) {
28
+ const h = crypto.createHash('sha256')
29
+ h.update(`llrt:${input.llrtVersion}\n`)
30
+ for (const f of [...input.files].sort()) {
31
+ try {
32
+ h.update(`${f}:${fs.statSync(f).mtimeMs}:${fs.statSync(f).size}\n`)
33
+ } catch (_) {
34
+ h.update(`${f}:missing\n`)
35
+ }
36
+ }
37
+ if (input.lockfile && fs.existsSync(input.lockfile)) {
38
+ h.update(fs.readFileSync(input.lockfile))
39
+ }
40
+ return h.digest('hex').slice(0, 24)
41
+ }
42
+
43
+ /**
44
+ * Content-based cache key for the EXPENSIVE tier. Hashes the built verify bundle
45
+ * (which already reflects every transitive source + transpile), plus the
46
+ * lockfile and LLRT version. Content-based ⇒ the same inputs hit the cache
47
+ * across machines/CI runners, not just where it was first computed (idea #29).
48
+ * @param {{ bundleFile: string, lockfile?: string, llrtVersion: string, fn?: string }} input
49
+ */
50
+ function bundleKey(input) {
51
+ const h = crypto.createHash('sha256')
52
+ h.update(`llrt:${input.llrtVersion}\nfn:${input.fn || ''}\n`)
53
+ try {
54
+ // Normalize the ONE volatile token esbuild injects — the random verify
55
+ // temp-dir name (llrt-verify-XXXXXX) appears in a path comment and would
56
+ // otherwise make every build hash differently, defeating the cache.
57
+ const code = fs.readFileSync(input.bundleFile, 'utf8').replace(/llrt-verify-[A-Za-z0-9]+/g, 'llrt-verify-X')
58
+ h.update(code)
59
+ } catch (_) {
60
+ h.update('no-bundle\n')
61
+ }
62
+ if (input.lockfile && fs.existsSync(input.lockfile)) h.update(fs.readFileSync(input.lockfile))
63
+ return h.digest('hex').slice(0, 24)
64
+ }
65
+
66
+ /** Find the nearest lockfile above a dir (npm/pnpm/yarn), or '' if none. */
67
+ function findLockfile(startDir) {
68
+ let dir = startDir
69
+ for (let i = 0; i < 8 && dir; i += 1) {
70
+ for (const name of ['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock', 'bun.lockb']) {
71
+ const p = path.join(dir, name)
72
+ if (fs.existsSync(p)) return p
73
+ }
74
+ const parent = path.dirname(dir)
75
+ if (parent === dir) break
76
+ dir = parent
77
+ }
78
+ return ''
79
+ }
80
+
81
+ /** @param {string} storeFile */
82
+ function loadStore(storeFile) {
83
+ try {
84
+ return JSON.parse(fs.readFileSync(storeFile, 'utf8'))
85
+ } catch (_) {
86
+ return {}
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Store the FULL verdict under a key so a cache hit can replay it verbatim.
92
+ * @param {string} storeFile @param {string} key @param {import('./verdict').FunctionVerdict} verdict
93
+ */
94
+ function saveVerdict(storeFile, key, verdict) {
95
+ const store = loadStore(storeFile)
96
+ store[key] = { savedForVersion: verdict.llrtVersion, verdict }
97
+ fs.mkdirSync(path.dirname(storeFile), { recursive: true })
98
+ fs.writeFileSync(storeFile, `${JSON.stringify(store, null, 2)}\n`)
99
+ return store[key]
100
+ }
101
+
102
+ /**
103
+ * @returns {null | import('./verdict').FunctionVerdict} the cached verdict, or null
104
+ */
105
+ function getCached(storeFile, key) {
106
+ const entry = loadStore(storeFile)[key]
107
+ return entry && entry.verdict ? entry.verdict : null
108
+ }
109
+
110
+ module.exports = { llrtableKey, bundleKey, findLockfile, saveVerdict, getCached, loadStore, DEFAULT_STORE }