@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/README.md +122 -0
- package/data/compat-table.0.8.1-beta.json +67 -0
- package/data/known-bad.json +16 -0
- package/data/known-quirks.0.8.1-beta.json +36 -0
- package/data/llrt-binary-checksums.0.8.1-beta.json +9 -0
- package/data/sdk-bundle.0.8.1-beta.json +40 -0
- package/package.json +73 -0
- package/src/binary-manager.js +152 -0
- package/src/build-for-llrt.js +137 -0
- package/src/cli.js +229 -0
- package/src/cold-start.js +51 -0
- package/src/corpus.js +116 -0
- package/src/deploy-verify.js +65 -0
- package/src/diff.js +113 -0
- package/src/error-map.js +38 -0
- package/src/import-graph.js +167 -0
- package/src/index.js +86 -0
- package/src/knowledge-base.js +139 -0
- package/src/local-compare.js +156 -0
- package/src/persist.js +110 -0
- package/src/run-under.js +52 -0
- package/src/runtime/aws-recorder.cjs +99 -0
- package/src/service.js +77 -0
- package/src/smart-ci-check.js +34 -0
- package/src/static-scan.js +195 -0
- package/src/verdict.js +133 -0
|
@@ -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 }
|