@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
package/src/run-under.js
ADDED
|
@@ -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 }
|