@palbase/backend 17.4.0 → 18.0.1
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/dist/bin/palbase-backend.cjs +1848 -0
- package/dist/bin/palbase-backend.cjs.map +1 -0
- package/dist/bin/palbase-backend.d.cts +1 -0
- package/dist/bin/palbase-backend.d.ts +1 -0
- package/dist/bin/palbase-backend.js +168 -0
- package/dist/bin/palbase-backend.js.map +1 -0
- package/dist/chunk-7D4SUZUM.js +38 -0
- package/dist/chunk-7D4SUZUM.js.map +1 -0
- package/dist/chunk-N32VDWKH.js +172 -0
- package/dist/chunk-N32VDWKH.js.map +1 -0
- package/dist/chunk-POYAFBLF.js +189 -0
- package/dist/chunk-POYAFBLF.js.map +1 -0
- package/dist/chunk-QMVK4X3V.js +200 -0
- package/dist/chunk-QMVK4X3V.js.map +1 -0
- package/dist/chunk-SSGAMC26.js +342 -0
- package/dist/chunk-SSGAMC26.js.map +1 -0
- package/dist/chunk-VYH4U7ZQ.js +1138 -0
- package/dist/chunk-VYH4U7ZQ.js.map +1 -0
- package/dist/{chunk-AAN642N5.js → chunk-W5ODXPY3.js} +2 -336
- package/dist/chunk-W5ODXPY3.js.map +1 -0
- package/dist/chunk-YL4C5NRY.js +90 -0
- package/dist/chunk-YL4C5NRY.js.map +1 -0
- package/dist/db/env.cjs.map +1 -1
- package/dist/db/env.d.cts +21 -1
- package/dist/db/env.d.ts +21 -1
- package/dist/db/index.cjs.map +1 -1
- package/dist/db/index.d.cts +2 -1
- package/dist/db/index.d.ts +2 -1
- package/dist/db/index.js +9 -6
- package/dist/{index-VLrU7rSW.d.ts → endpoint-B0LpZixz.d.cts} +124 -685
- package/dist/{index-BA_oFAz9.d.cts → endpoint-B0LpZixz.d.ts} +124 -685
- package/dist/engine/index.cjs +1797 -0
- package/dist/engine/index.cjs.map +1 -0
- package/dist/engine/index.d.cts +7 -0
- package/dist/engine/index.d.ts +7 -0
- package/dist/engine/index.js +43 -0
- package/dist/engine/index.js.map +1 -0
- package/dist/index-B46CGNvx.d.cts +839 -0
- package/dist/index-BGSCWlUa.d.cts +674 -0
- package/dist/index-DZDUMth5.d.ts +839 -0
- package/dist/index-g-EzitI-.d.ts +674 -0
- package/dist/index.cjs +1031 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +290 -532
- package/dist/index.d.ts +290 -532
- package/dist/index.js +999 -509
- package/dist/index.js.map +1 -1
- package/dist/openapi/index.cjs +6464 -0
- package/dist/openapi/index.cjs.map +1 -0
- package/dist/openapi/index.d.cts +170 -0
- package/dist/openapi/index.d.ts +170 -0
- package/dist/openapi/index.js +6248 -0
- package/dist/openapi/index.js.map +1 -0
- package/dist/registry-3BLYv4si.d.ts +338 -0
- package/dist/registry-Cw0YEYCg.d.cts +338 -0
- package/dist/test/index.js +2 -0
- package/dist/test/index.js.map +1 -1
- package/docs/database.md +16 -3
- package/docs/llms-full.txt +16 -3
- package/package.json +43 -13
- package/stager/package.json +4 -0
- package/stager/return_types.js +338 -0
- package/stager/stage.js +78 -0
- package/stager/throw_analysis.js +726 -0
- package/template/AGENTS.md +261 -0
- package/template/config/secrets.ts +24 -0
- package/template/controllers/health.controller.ts +30 -0
- package/template/db/schema.ts +35 -0
- package/template/package.json +18 -0
- package/template/tsconfig.json +30 -0
- package/LICENSE +0 -21
- package/dist/chunk-AAN642N5.js.map +0 -1
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Palbase Backend Runtime — Per-Endpoint Throw Inference (typed backend errors)
|
|
5
|
+
*
|
|
6
|
+
* Sibling of return_types.js: a parser-only (TS parser AST, no TypeChecker)
|
|
7
|
+
* pass that, for each controller route method, collects the error classes the
|
|
8
|
+
* method can throw — directly or through the resolvable slice of its call
|
|
9
|
+
* graph — and appends a `recordThrows(...)` IIFE per op so the live route
|
|
10
|
+
* registry (RouteMeta.throws), the meta extractor, and the worker's
|
|
11
|
+
* conformance guard all see the inferred set. The wire shape downstream is the
|
|
12
|
+
* existing `x-palbase-errors` v1 extension.
|
|
13
|
+
*
|
|
14
|
+
* Resolution rules (anything else is SKIPPED SILENTLY — misses degrade to the
|
|
15
|
+
* client's `.other` case and light up the runtime conformance guard):
|
|
16
|
+
*
|
|
17
|
+
* throw new X(...) X via import map / local decl:
|
|
18
|
+
* - SDK named classes (NotFound/Conflict/…): fixed
|
|
19
|
+
* code table; a LITERAL second-arg code override is
|
|
20
|
+
* honored, a non-literal override drops the site.
|
|
21
|
+
* - HttpError/PalError: literal code arg → that code
|
|
22
|
+
* under the name "PalError"; non-literal → drop.
|
|
23
|
+
* - defineError classes: the defining file's
|
|
24
|
+
* defineError("<lit>", <lit>, …) literals. Non-
|
|
25
|
+
* literal args there are the ONE HARD ERROR this
|
|
26
|
+
* analysis raises (deploy fails naming the file).
|
|
27
|
+
* svc.m(...) `svc` imported/local → defining module →
|
|
28
|
+
* `const svc = new SvcClass(…)` → class decl →
|
|
29
|
+
* method body, recurse.
|
|
30
|
+
* Cls.m(...) imported/local class decl → STATIC method body.
|
|
31
|
+
* fn(...) imported/local function decl (or const fn = …).
|
|
32
|
+
* this.m(...) same-class method body.
|
|
33
|
+
*
|
|
34
|
+
* The THROWN VALUE itself is lowered by resolveThrownExpression, which handles
|
|
35
|
+
* `throw new X(...)`, `throw <local bound to new X(...)>`, and
|
|
36
|
+
* `throw <call>` where the callee RETURNS the error (the factory idiom).
|
|
37
|
+
* NOT resolved: a helper declared as a class PROPERTY (`private m = () => …`),
|
|
38
|
+
* because classMethod only matches MethodDeclarations — such a call is skipped
|
|
39
|
+
* silently like any other unresolvable shape.
|
|
40
|
+
*
|
|
41
|
+
* Transitive walk is depth-bounded (8), cycle-safe via a (file, symbol)
|
|
42
|
+
* visited set, and PROJECT FILES ONLY: relative imports resolve against the
|
|
43
|
+
* REAL project tree (`.ts` / `/index.ts` candidates); bare specifiers other
|
|
44
|
+
* than @palbase/backend never resolve. Per-op descriptors dedupe by code.
|
|
45
|
+
*
|
|
46
|
+
* This module is SHARED VERBATIM by the deploy stager and `palbase build`
|
|
47
|
+
* (the same parity rule as return_types.js). It needs only `typescript` on
|
|
48
|
+
* NODE_PATH and never touches the filesystem itself — the caller injects
|
|
49
|
+
* { readFile, fileExists }.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
const path = require('path');
|
|
53
|
+
|
|
54
|
+
let ts = null;
|
|
55
|
+
function loadTS() {
|
|
56
|
+
if (ts) return ts;
|
|
57
|
+
let mod;
|
|
58
|
+
try {
|
|
59
|
+
mod = require('typescript');
|
|
60
|
+
} catch (e) {
|
|
61
|
+
throw new Error(TS_PARSER_HELP('the `typescript` package could not be loaded (' + e.message + ')'));
|
|
62
|
+
}
|
|
63
|
+
// TypeScript 7 (Go-native compiler) exports only { version, versionMajorMinor }
|
|
64
|
+
// from its CommonJS entry — no createSourceFile, no ScriptTarget. Say so instead
|
|
65
|
+
// of dying on "Cannot read properties of undefined". Twin of return_types.js.
|
|
66
|
+
if (typeof mod.createSourceFile !== 'function' || !mod.ScriptTarget) {
|
|
67
|
+
throw new Error(TS_PARSER_HELP(
|
|
68
|
+
'the resolved `typescript` (v' + (mod.version || 'unknown') + ') has no compiler API — ' +
|
|
69
|
+
'TypeScript 7 ships the Go-native compiler, whose CommonJS build exposes version metadata only',
|
|
70
|
+
));
|
|
71
|
+
}
|
|
72
|
+
ts = mod;
|
|
73
|
+
return ts;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// One actionable message for both parser-load failures (verbatim twin of the
|
|
77
|
+
// return_types.js copy). The CLI/pod normally puts its OWN pinned TypeScript 5
|
|
78
|
+
// ahead of the project on NODE_PATH; reaching this means that provisioning was
|
|
79
|
+
// skipped (offline first run) and the project's typescript is unusable as a parser.
|
|
80
|
+
function TS_PARSER_HELP(reason) {
|
|
81
|
+
return (
|
|
82
|
+
'palbase needs the TypeScript 5 compiler API to analyze controller throw sites, but ' +
|
|
83
|
+
reason +
|
|
84
|
+
'.\n fix: npm install --save-dev typescript@5 (or re-run once online: the CLI installs its own pinned parser)'
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
class ThrowAnalysisError extends Error {}
|
|
89
|
+
|
|
90
|
+
const SDK_SPECIFIER = '@palbase/backend';
|
|
91
|
+
|
|
92
|
+
// Fixed default-code table for the SDK's named error classes (mirrors
|
|
93
|
+
// backend/src/errors.ts — ctor (message?, code?, data?)). Pre-seeded as
|
|
94
|
+
// built-ins in the SDK error registry, so extract/openapi resolve their
|
|
95
|
+
// status/hasData by code.
|
|
96
|
+
const SDK_NAMED_ERRORS = {
|
|
97
|
+
BadRequest: 'bad_request',
|
|
98
|
+
Unauthorized: 'unauthorized',
|
|
99
|
+
Forbidden: 'forbidden',
|
|
100
|
+
NotFound: 'not_found',
|
|
101
|
+
Conflict: 'conflict',
|
|
102
|
+
TooManyRequests: 'too_many_requests',
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// The named built-ins whose ctor is DATA-FIRST (`new X(data, message?)`) rather
|
|
106
|
+
// than the data-less `(message?, code?)` shape. Their 2nd arg is a message, NOT
|
|
107
|
+
// a code override — so the canonical code from the table always wins and the
|
|
108
|
+
// site must NOT be dropped when the 2nd arg is a non-string (it is the data
|
|
109
|
+
// object or a variable). Must stay in lockstep with the SDK: these are the
|
|
110
|
+
// errors error-registry.ts pre-seeds with a dataSchema.
|
|
111
|
+
const SDK_DATA_FIRST_ERRORS = new Set(['BadRequest', 'TooManyRequests']);
|
|
112
|
+
|
|
113
|
+
// Raw SDK error classes — ctor (status, code, message, data?): the code is the
|
|
114
|
+
// SECOND argument. Both surface under the name "PalError" (HttpError is the
|
|
115
|
+
// base; the distinction carries no client meaning).
|
|
116
|
+
const SDK_RAW_ERRORS = new Set(['HttpError', 'PalError']);
|
|
117
|
+
|
|
118
|
+
// Maximum call-graph depth: the route method body is depth 0; each resolved
|
|
119
|
+
// call enters its target body at depth+1. Bodies deeper than MAX_DEPTH are not
|
|
120
|
+
// entered (their throws degrade to `.other` + the runtime guard).
|
|
121
|
+
const MAX_DEPTH = 8;
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// File model
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
// parseFile lowers one source file into the binding maps the resolver needs.
|
|
128
|
+
// Cached per analysis run (ctx.files) so shared services parse once.
|
|
129
|
+
function parseFile(ctx, filePath, sourceText) {
|
|
130
|
+
const tsapi = loadTS();
|
|
131
|
+
const sf = tsapi.createSourceFile(
|
|
132
|
+
filePath,
|
|
133
|
+
sourceText,
|
|
134
|
+
tsapi.ScriptTarget.ES2022,
|
|
135
|
+
/* setParentNodes */ true,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const imports = new Map(); // localName → { specifier, original }
|
|
139
|
+
const classes = new Map(); // className → ClassDeclaration
|
|
140
|
+
const functions = new Map(); // fnName → node with a .body (FunctionDeclaration / arrow / fn-expr)
|
|
141
|
+
const vars = new Map(); // varName → initializer node (may be undefined)
|
|
142
|
+
|
|
143
|
+
for (const stmt of sf.statements) {
|
|
144
|
+
if (tsapi.isImportDeclaration(stmt) && stmt.importClause) {
|
|
145
|
+
const spec = stmt.moduleSpecifier.text;
|
|
146
|
+
const named = stmt.importClause.namedBindings;
|
|
147
|
+
if (named && tsapi.isNamedImports(named)) {
|
|
148
|
+
for (const el of named.elements) {
|
|
149
|
+
const local = el.name.text;
|
|
150
|
+
const original = el.propertyName ? el.propertyName.text : local;
|
|
151
|
+
imports.set(local, { specifier: spec, original });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} else if (tsapi.isClassDeclaration(stmt) && stmt.name) {
|
|
155
|
+
classes.set(stmt.name.text, stmt);
|
|
156
|
+
} else if (tsapi.isFunctionDeclaration(stmt) && stmt.name && stmt.body) {
|
|
157
|
+
functions.set(stmt.name.text, stmt);
|
|
158
|
+
} else if (tsapi.isVariableStatement(stmt)) {
|
|
159
|
+
for (const d of stmt.declarationList.declarations) {
|
|
160
|
+
if (tsapi.isIdentifier(d.name)) {
|
|
161
|
+
vars.set(d.name.text, d.initializer);
|
|
162
|
+
// `const fn = () => {}` / `const fn = function () {}` is callable.
|
|
163
|
+
if (
|
|
164
|
+
d.initializer &&
|
|
165
|
+
(tsapi.isArrowFunction(d.initializer) || tsapi.isFunctionExpression(d.initializer)) &&
|
|
166
|
+
d.initializer.body
|
|
167
|
+
) {
|
|
168
|
+
functions.set(d.name.text, d.initializer);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return { path: filePath, sf, imports, classes, functions, vars };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// loadFile reads + parses a project file through the deps seam (null when the
|
|
179
|
+
// file does not exist / cannot be read).
|
|
180
|
+
function loadFile(ctx, filePath) {
|
|
181
|
+
if (ctx.files.has(filePath)) return ctx.files.get(filePath);
|
|
182
|
+
let info = null;
|
|
183
|
+
const text = ctx.deps.readFile(filePath);
|
|
184
|
+
if (typeof text === 'string') {
|
|
185
|
+
info = parseFile(ctx, filePath, text);
|
|
186
|
+
}
|
|
187
|
+
ctx.files.set(filePath, info);
|
|
188
|
+
return info;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// resolveModuleFile resolves a RELATIVE import specifier against the importing
|
|
192
|
+
// file's REAL directory: `<spec>.ts` then `<spec>/index.ts`. Bare specifiers
|
|
193
|
+
// (node_modules) never resolve — project files only.
|
|
194
|
+
function resolveModuleFile(ctx, fromPath, specifier) {
|
|
195
|
+
if (!specifier.startsWith('./') && !specifier.startsWith('../')) return null;
|
|
196
|
+
const base = path.resolve(path.dirname(fromPath), specifier);
|
|
197
|
+
// Clamp to the project root — an `import "../../../etc/x"` chain must not
|
|
198
|
+
// walk the analyzer outside the project tree (spec: project files only).
|
|
199
|
+
const rel = path.relative(ctx.projectRoot, base);
|
|
200
|
+
if (rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel)) return null;
|
|
201
|
+
const candidates = base.endsWith('.ts')
|
|
202
|
+
? [base]
|
|
203
|
+
: [base + '.ts', path.join(base, 'index.ts')];
|
|
204
|
+
for (const c of candidates) {
|
|
205
|
+
if (ctx.deps.fileExists(c)) return c;
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// Binding resolution (across files, bounded hops)
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
// resolveBinding resolves `name` in `fileInfo` to a concrete declaration:
|
|
215
|
+
// { kind: 'sdk', original } — named import from @palbase/backend
|
|
216
|
+
// { kind: 'class', file, node } — class declaration
|
|
217
|
+
// { kind: 'function', file, node } — function decl / const fn
|
|
218
|
+
// { kind: 'var', file, node /*initializer*/ } — other variable initializer
|
|
219
|
+
// Follows re-imports through project files (bounded, cycle-safe). null = not
|
|
220
|
+
// resolvable (dynamic / node_modules / missing file).
|
|
221
|
+
function resolveBinding(ctx, fileInfo, name, hops) {
|
|
222
|
+
if (!fileInfo || hops > MAX_DEPTH) return null;
|
|
223
|
+
|
|
224
|
+
const imp = fileInfo.imports.get(name);
|
|
225
|
+
if (imp) {
|
|
226
|
+
if (imp.specifier === SDK_SPECIFIER) {
|
|
227
|
+
return { kind: 'sdk', original: imp.original };
|
|
228
|
+
}
|
|
229
|
+
const target = resolveModuleFile(ctx, fileInfo.path, imp.specifier);
|
|
230
|
+
if (!target) return null;
|
|
231
|
+
const targetInfo = loadFile(ctx, target);
|
|
232
|
+
return resolveBinding(ctx, targetInfo, imp.original, hops + 1);
|
|
233
|
+
}
|
|
234
|
+
if (fileInfo.classes.has(name)) {
|
|
235
|
+
return { kind: 'class', file: fileInfo, node: fileInfo.classes.get(name) };
|
|
236
|
+
}
|
|
237
|
+
if (fileInfo.functions.has(name)) {
|
|
238
|
+
return { kind: 'function', file: fileInfo, node: fileInfo.functions.get(name) };
|
|
239
|
+
}
|
|
240
|
+
if (fileInfo.vars.has(name)) {
|
|
241
|
+
return { kind: 'var', file: fileInfo, node: fileInfo.vars.get(name) };
|
|
242
|
+
}
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// Throw-site lowering
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
// literalString returns the literal value of a string-literal-ish node, else
|
|
251
|
+
// null (a template WITH substitutions, an identifier, a call, … are not
|
|
252
|
+
// literals).
|
|
253
|
+
function literalString(tsapi, node) {
|
|
254
|
+
if (!node) return null;
|
|
255
|
+
if (tsapi.isStringLiteral(node)) return node.text;
|
|
256
|
+
if (tsapi.isNoSubstitutionTemplateLiteral(node)) return node.text;
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// addDescriptor appends {name, code} to out, deduping by code (first name
|
|
261
|
+
// wins — names are cosmetic, the code is the join key everywhere downstream).
|
|
262
|
+
function addDescriptor(out, name, code) {
|
|
263
|
+
if (out.some((d) => d.code === code)) return;
|
|
264
|
+
out.push({ name, code });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// resolveThrownNew lowers ONE `throw new X(args)` site into a descriptor (or
|
|
268
|
+
// silently skips it). The ONLY hard error: X is a defineError class whose
|
|
269
|
+
// defining call has non-literal code/status args.
|
|
270
|
+
function resolveThrownNew(ctx, fileInfo, newExpr, out) {
|
|
271
|
+
const tsapi = loadTS();
|
|
272
|
+
if (!tsapi.isIdentifier(newExpr.expression)) return; // computed/namespaced ctor → skip
|
|
273
|
+
const className = newExpr.expression.text;
|
|
274
|
+
const args = newExpr.arguments || [];
|
|
275
|
+
|
|
276
|
+
const binding = resolveBinding(ctx, fileInfo, className, 0);
|
|
277
|
+
if (!binding) return;
|
|
278
|
+
|
|
279
|
+
if (binding.kind === 'sdk') {
|
|
280
|
+
const original = binding.original;
|
|
281
|
+
if (SDK_NAMED_ERRORS[original]) {
|
|
282
|
+
// Two ctor shapes coexist among the named built-ins:
|
|
283
|
+
// data-first (data, message?): `new TooManyRequests({retryAfter}, "msg")`
|
|
284
|
+
// — the 2nd arg is a MESSAGE, never a code. The canonical table code
|
|
285
|
+
// always wins; the site must NOT be dropped when the 1st/2nd arg is a
|
|
286
|
+
// non-string (the old "non-literal 2nd arg → return" silently lost
|
|
287
|
+
// these typed-data built-in throws — caught live on the share/bulk
|
|
288
|
+
// endpoints).
|
|
289
|
+
// data-less (message?, code?): `new NotFound("msg", "custom_code")` — a
|
|
290
|
+
// string-literal 2nd arg overrides the canonical code; a non-literal
|
|
291
|
+
// override is unresolvable and still degrades the site to .other.
|
|
292
|
+
if (SDK_DATA_FIRST_ERRORS.has(original)) {
|
|
293
|
+
addDescriptor(out, original, SDK_NAMED_ERRORS[original]);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (args.length >= 2) {
|
|
297
|
+
const override = literalString(tsapi, args[1]);
|
|
298
|
+
if (override === null) return; // non-literal override → site degrades to .other
|
|
299
|
+
addDescriptor(out, original, override);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
addDescriptor(out, original, SDK_NAMED_ERRORS[original]);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (SDK_RAW_ERRORS.has(original)) {
|
|
306
|
+
// ctor (status, code, message, data?) — code is the SECOND argument.
|
|
307
|
+
const code = literalString(tsapi, args[1]);
|
|
308
|
+
if (code === null) return;
|
|
309
|
+
addDescriptor(out, 'PalError', code);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
return; // some other SDK export → not an error class we know
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (binding.kind === 'var') {
|
|
316
|
+
// `export const TodoLocked = defineError("todo_locked", 409, schema?)`.
|
|
317
|
+
const init = binding.node;
|
|
318
|
+
if (
|
|
319
|
+
init &&
|
|
320
|
+
tsapi.isCallExpression(init) &&
|
|
321
|
+
tsapi.isIdentifier(init.expression) &&
|
|
322
|
+
init.expression.text === 'defineError'
|
|
323
|
+
) {
|
|
324
|
+
const dargs = init.arguments || [];
|
|
325
|
+
const code = literalString(tsapi, dargs[0]);
|
|
326
|
+
const statusOk = dargs[1] && tsapi.isNumericLiteral(dargs[1]);
|
|
327
|
+
if (code === null || !statusOk) {
|
|
328
|
+
throw new ThrowAnalysisError(
|
|
329
|
+
`${binding.file.path} — defineError(...) for "${className}" must be called with ` +
|
|
330
|
+
`literal arguments (a string-literal code and a numeric-literal status); the ` +
|
|
331
|
+
`static analyzer reads these literals to type the endpoint's errors`,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
addDescriptor(out, className, code);
|
|
335
|
+
}
|
|
336
|
+
// Any other variable-class (hand-rolled subclass, factory, …) → skip.
|
|
337
|
+
}
|
|
338
|
+
// A plain local class declaration → no code to read → skip.
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// Call-graph walk
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
// classMethod finds a (static or instance) method body on a class declaration.
|
|
346
|
+
// classProperty finds a non-static property declaration by name on a class.
|
|
347
|
+
function classProperty(tsapi, classNode, name) {
|
|
348
|
+
for (const m of classNode.members) {
|
|
349
|
+
if (!tsapi.isPropertyDeclaration(m)) continue;
|
|
350
|
+
if (!m.name || !tsapi.isIdentifier(m.name) || m.name.text !== name) continue;
|
|
351
|
+
const isStatic = (m.modifiers || []).some((x) => x.kind === tsapi.SyntaxKind.StaticKeyword);
|
|
352
|
+
if (isStatic) continue;
|
|
353
|
+
return m;
|
|
354
|
+
}
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// resolveInstanceMethodFromInit resolves `<init>.<methodName>` where <init> is
|
|
359
|
+
// a service-instance initializer expression: `new SvcClass(...)` directly, or
|
|
360
|
+
// an identifier that (through imports / one var hop) reaches the singleton
|
|
361
|
+
// (`export const todoService = new TodoService()`). Returns a walk target or
|
|
362
|
+
// null.
|
|
363
|
+
function resolveInstanceMethodFromInit(ctx, fileInfo, init, methodName, hop = 0) {
|
|
364
|
+
const tsapi = loadTS();
|
|
365
|
+
if (!init || hop > MAX_DEPTH) return null;
|
|
366
|
+
if (tsapi.isNewExpression(init) && tsapi.isIdentifier(init.expression)) {
|
|
367
|
+
const clsBinding = resolveBinding(ctx, fileInfo, init.expression.text, 0);
|
|
368
|
+
if (!clsBinding || clsBinding.kind !== 'class') return null;
|
|
369
|
+
const m = classMethod(tsapi, clsBinding.node, methodName, /* static */ false);
|
|
370
|
+
if (!m) return null;
|
|
371
|
+
const clsName = clsBinding.node.name ? clsBinding.node.name.text : '<anon>';
|
|
372
|
+
return {
|
|
373
|
+
file: clsBinding.file,
|
|
374
|
+
body: m.body,
|
|
375
|
+
classNode: clsBinding.node,
|
|
376
|
+
key: `${clsBinding.file.path}#${clsName}.${methodName}`,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
if (tsapi.isIdentifier(init)) {
|
|
380
|
+
const binding = resolveBinding(ctx, fileInfo, init.text, 0);
|
|
381
|
+
if (!binding) return null;
|
|
382
|
+
if (binding.kind === 'var') {
|
|
383
|
+
return resolveInstanceMethodFromInit(ctx, binding.file, binding.node, methodName, hop + 1);
|
|
384
|
+
}
|
|
385
|
+
if (binding.kind === 'class') {
|
|
386
|
+
// `private todos = TodoService` (class object itself) — instance call
|
|
387
|
+
// through a class REFERENCE is not the singleton idiom; skip.
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function classMethod(tsapi, classNode, methodName, wantStatic) {
|
|
395
|
+
for (const m of classNode.members) {
|
|
396
|
+
if (!tsapi.isMethodDeclaration(m) || !m.body) continue;
|
|
397
|
+
if (m.name.getText(classNode.getSourceFile()) !== methodName) continue;
|
|
398
|
+
const isStatic = (m.modifiers || []).some((mod) => mod.kind === tsapi.SyntaxKind.StaticKeyword);
|
|
399
|
+
if (isStatic !== wantStatic) continue;
|
|
400
|
+
return m;
|
|
401
|
+
}
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// resolveCallTarget lowers ONE call expression into the body to recurse into:
|
|
406
|
+
// { file, body, classNode|null, key } — or null (skip silently).
|
|
407
|
+
function resolveCallTarget(ctx, fileInfo, classNode, callExpr) {
|
|
408
|
+
const tsapi = loadTS();
|
|
409
|
+
const callee = callExpr.expression;
|
|
410
|
+
|
|
411
|
+
// fn(...) — plain identifier call.
|
|
412
|
+
if (tsapi.isIdentifier(callee)) {
|
|
413
|
+
const binding = resolveBinding(ctx, fileInfo, callee.text, 0);
|
|
414
|
+
if (binding && binding.kind === 'function' && binding.node.body) {
|
|
415
|
+
return {
|
|
416
|
+
file: binding.file,
|
|
417
|
+
body: binding.node.body,
|
|
418
|
+
classNode: null,
|
|
419
|
+
key: `${binding.file.path}#fn:${callee.text}`,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// obj.m(...) — property access on `this` or a plain identifier.
|
|
426
|
+
if (tsapi.isPropertyAccessExpression(callee) && tsapi.isIdentifier(callee.name)) {
|
|
427
|
+
const methodName = callee.name.text;
|
|
428
|
+
|
|
429
|
+
// this.m(...) — same-class method.
|
|
430
|
+
if (callee.expression.kind === tsapi.SyntaxKind.ThisKeyword) {
|
|
431
|
+
if (!classNode) return null;
|
|
432
|
+
const m = classMethod(tsapi, classNode, methodName, /* static */ false);
|
|
433
|
+
if (!m) return null;
|
|
434
|
+
const clsName = classNode.name ? classNode.name.text : '<anon>';
|
|
435
|
+
return {
|
|
436
|
+
file: fileInfo,
|
|
437
|
+
body: m.body,
|
|
438
|
+
classNode,
|
|
439
|
+
key: `${fileInfo.path}#${clsName}.${methodName}`,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// this.<prop>.m(...) — class property holding a service instance
|
|
444
|
+
// (`private todos = todoService;` / `private svc = new SvcClass();`) —
|
|
445
|
+
// the documented thin-controller idiom. Resolve through the PROPERTY
|
|
446
|
+
// DECLARATION's initializer.
|
|
447
|
+
if (
|
|
448
|
+
tsapi.isPropertyAccessExpression(callee.expression) &&
|
|
449
|
+
callee.expression.expression.kind === tsapi.SyntaxKind.ThisKeyword &&
|
|
450
|
+
tsapi.isIdentifier(callee.expression.name)
|
|
451
|
+
) {
|
|
452
|
+
if (!classNode) return null;
|
|
453
|
+
const propName = callee.expression.name.text;
|
|
454
|
+
const prop = classProperty(tsapi, classNode, propName);
|
|
455
|
+
if (!prop || !prop.initializer) return null;
|
|
456
|
+
return resolveInstanceMethodFromInit(ctx, fileInfo, prop.initializer, methodName);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (!tsapi.isIdentifier(callee.expression)) return null; // a.b.c(...) → skip
|
|
460
|
+
const objName = callee.expression.text;
|
|
461
|
+
const binding = resolveBinding(ctx, fileInfo, objName, 0);
|
|
462
|
+
if (!binding) return null;
|
|
463
|
+
|
|
464
|
+
// Cls.staticMethod(...).
|
|
465
|
+
if (binding.kind === 'class') {
|
|
466
|
+
const m = classMethod(tsapi, binding.node, methodName, /* static */ true);
|
|
467
|
+
if (!m) return null;
|
|
468
|
+
const clsName = binding.node.name ? binding.node.name.text : '<anon>';
|
|
469
|
+
return {
|
|
470
|
+
file: binding.file,
|
|
471
|
+
body: m.body,
|
|
472
|
+
classNode: binding.node,
|
|
473
|
+
key: `${binding.file.path}#${clsName}.static.${methodName}`,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// svc.method(...) where svc = new SvcClass(...) (the singleton idiom).
|
|
478
|
+
if (binding.kind === 'var') {
|
|
479
|
+
return resolveInstanceMethodFromInit(ctx, binding.file, binding.node, methodName);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return null; // dynamic dispatch / computed / IIFE / namespaced → skip
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// resolveThrownExpression lowers the THROWN VALUE, which is not always a `new`.
|
|
487
|
+
// Three shapes reach a defineError class and only the first used to be handled:
|
|
488
|
+
//
|
|
489
|
+
// throw new X(...) the literal form
|
|
490
|
+
// throw this.mk(...) an error FACTORY — the helper builds and RETURNS it
|
|
491
|
+
// const e = new X(); throw e built into a local first
|
|
492
|
+
//
|
|
493
|
+
// The last two produced an EMPTY error set for the whole route: no 5xx in the
|
|
494
|
+
// spec, no typed class in the generated client, and no diagnostic anywhere —
|
|
495
|
+
// while the code kept throwing the error at runtime, so it "worked" and the gap
|
|
496
|
+
// only showed up as a client that could not name what it caught.
|
|
497
|
+
//
|
|
498
|
+
// The factory case does NOT double-count. collectFromBody's generic call branch
|
|
499
|
+
// recurses into the callee to collect its THROWS under `target.key`; this pass
|
|
500
|
+
// collects the RETURNED constructions under `target.key + '#returns'`, so the
|
|
501
|
+
// two walks are independent and each runs at most once.
|
|
502
|
+
function resolveThrownExpression(ctx, fileInfo, classNode, expr, out, depth, visited) {
|
|
503
|
+
const tsapi = loadTS();
|
|
504
|
+
if (tsapi.isNewExpression(expr)) {
|
|
505
|
+
resolveThrownNew(ctx, fileInfo, expr, out);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (tsapi.isIdentifier(expr)) {
|
|
509
|
+
const init = localNewInitializer(tsapi, expr);
|
|
510
|
+
if (init) resolveThrownNew(ctx, fileInfo, init, out);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (tsapi.isCallExpression(expr)) {
|
|
514
|
+
const target = resolveCallTarget(ctx, fileInfo, classNode, expr);
|
|
515
|
+
if (!target) return;
|
|
516
|
+
const key = target.key + '#returns';
|
|
517
|
+
if (depth + 1 > MAX_DEPTH || visited.has(key)) return;
|
|
518
|
+
visited.add(key);
|
|
519
|
+
collectReturnedNew(ctx, target.file, target.classNode, target.body, out, depth + 1, visited);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// localNewInitializer walks OUT from a thrown identifier to the
|
|
524
|
+
// `const e = new X()` that declared it, stopping at the enclosing function so a
|
|
525
|
+
// same-named local in a sibling scope can't be picked up. Only a DIRECT `new`
|
|
526
|
+
// initializer resolves; a reassigned or conditionally-built local is one of the
|
|
527
|
+
// shapes this analysis skips silently by design.
|
|
528
|
+
function localNewInitializer(tsapi, ident) {
|
|
529
|
+
const name = ident.text;
|
|
530
|
+
for (let node = ident.parent; node; node = node.parent) {
|
|
531
|
+
let found = null;
|
|
532
|
+
const scan = (child) => {
|
|
533
|
+
if (found) return;
|
|
534
|
+
if (
|
|
535
|
+
tsapi.isVariableDeclaration(child) &&
|
|
536
|
+
tsapi.isIdentifier(child.name) &&
|
|
537
|
+
child.name.text === name &&
|
|
538
|
+
child.initializer &&
|
|
539
|
+
tsapi.isNewExpression(child.initializer)
|
|
540
|
+
) {
|
|
541
|
+
found = child.initializer;
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
tsapi.forEachChild(child, scan);
|
|
545
|
+
};
|
|
546
|
+
tsapi.forEachChild(node, scan);
|
|
547
|
+
if (found) return found;
|
|
548
|
+
if (tsapi.isFunctionLike(node) || tsapi.isSourceFile(node)) break;
|
|
549
|
+
}
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// collectReturnedNew collects the error constructions a FACTORY hands back:
|
|
554
|
+
// `return new X(...)`, the concise arrow body `() => new X(...)`, and one more
|
|
555
|
+
// hop for `return this.other()` so a two-level factory still resolves. It walks
|
|
556
|
+
// RETURNS only — a helper that merely constructs an error nobody throws must not
|
|
557
|
+
// become one of the route's declared error responses.
|
|
558
|
+
function collectReturnedNew(ctx, fileInfo, classNode, body, out, depth, visited) {
|
|
559
|
+
const tsapi = loadTS();
|
|
560
|
+
// A concise arrow body IS the returned expression — there is no ReturnStatement.
|
|
561
|
+
if (!tsapi.isBlock(body)) {
|
|
562
|
+
resolveThrownExpression(ctx, fileInfo, classNode, body, out, depth, visited);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
const visit = (node) => {
|
|
566
|
+
if (tsapi.isReturnStatement(node)) {
|
|
567
|
+
if (node.expression) {
|
|
568
|
+
resolveThrownExpression(ctx, fileInfo, classNode, node.expression, out, depth, visited);
|
|
569
|
+
}
|
|
570
|
+
return; // the returned expression is handled; do not re-walk it
|
|
571
|
+
}
|
|
572
|
+
tsapi.forEachChild(node, visit);
|
|
573
|
+
};
|
|
574
|
+
tsapi.forEachChild(body, visit);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// collectFromBody walks one body subtree: lowers every throw site (see
|
|
578
|
+
// resolveThrownExpression for the shapes) and recurses into every resolvable
|
|
579
|
+
// call (bounded by MAX_DEPTH + visited).
|
|
580
|
+
function collectFromBody(ctx, fileInfo, classNode, body, out, depth, visited) {
|
|
581
|
+
const tsapi = loadTS();
|
|
582
|
+
|
|
583
|
+
const visit = (node) => {
|
|
584
|
+
if (tsapi.isThrowStatement(node) && node.expression) {
|
|
585
|
+
resolveThrownExpression(ctx, fileInfo, classNode, node.expression, out, depth, visited);
|
|
586
|
+
} else if (tsapi.isCallExpression(node)) {
|
|
587
|
+
const target = resolveCallTarget(ctx, fileInfo, classNode, node);
|
|
588
|
+
if (target && depth + 1 <= MAX_DEPTH && !visited.has(target.key)) {
|
|
589
|
+
visited.add(target.key);
|
|
590
|
+
collectFromBody(ctx, target.file, target.classNode, target.body, out, depth + 1, visited);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
tsapi.forEachChild(node, visit);
|
|
594
|
+
};
|
|
595
|
+
tsapi.forEachChild(body, visit);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ---------------------------------------------------------------------------
|
|
599
|
+
// Public API
|
|
600
|
+
// ---------------------------------------------------------------------------
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* analyzeThrows parses one controller source and infers, per route method, the
|
|
604
|
+
* throwable error descriptors `[{name, code}]` (deduped by code).
|
|
605
|
+
*
|
|
606
|
+
* @param {string} sourceText the controller's .ts source
|
|
607
|
+
* @param {string} controllerPath the controller's REAL path (relative imports
|
|
608
|
+
* resolve against its directory)
|
|
609
|
+
* @param {{readFile: (p:string)=>string|null, fileExists:(p:string)=>boolean,
|
|
610
|
+
* projectRoot?: string}} deps
|
|
611
|
+
* @returns {{className: string|null, ops: Record<string, Array<{name:string,code:string}>>}
|
|
612
|
+
* | {error: string}}
|
|
613
|
+
* `{error}` is returned ONLY for the defineError non-literal-args violation
|
|
614
|
+
* (the one hard error); every other unresolvable shape is skipped silently.
|
|
615
|
+
*/
|
|
616
|
+
function analyzeThrows(sourceText, controllerPath, deps) {
|
|
617
|
+
const tsapi = loadTS();
|
|
618
|
+
// PROJECT FILES ONLY: resolution is clamped under the project root — by
|
|
619
|
+
// default the parent of the controllers/ dir; callers that know the real
|
|
620
|
+
// root (the stager / dev-server) pass deps.projectRoot explicitly.
|
|
621
|
+
const projectRoot =
|
|
622
|
+
(deps && deps.projectRoot) || path.dirname(path.dirname(path.resolve(controllerPath)));
|
|
623
|
+
const ctx = { deps, files: new Map(), projectRoot };
|
|
624
|
+
const fileInfo = parseFile(ctx, controllerPath, sourceText);
|
|
625
|
+
ctx.files.set(controllerPath, fileInfo);
|
|
626
|
+
|
|
627
|
+
let className = null;
|
|
628
|
+
const ops = {};
|
|
629
|
+
|
|
630
|
+
try {
|
|
631
|
+
for (const stmt of fileInfo.sf.statements) {
|
|
632
|
+
if (!tsapi.isClassDeclaration(stmt)) continue;
|
|
633
|
+
const decos = tsapi.getDecorators ? tsapi.getDecorators(stmt) || [] : [];
|
|
634
|
+
const isController = decos.some((d) => {
|
|
635
|
+
const ex = d.expression;
|
|
636
|
+
const callee = tsapi.isCallExpression(ex) ? ex.expression : ex;
|
|
637
|
+
return callee && callee.getText(fileInfo.sf) === 'Controller';
|
|
638
|
+
});
|
|
639
|
+
if (!isController) continue;
|
|
640
|
+
className = stmt.name ? stmt.name.text : null;
|
|
641
|
+
|
|
642
|
+
for (const m of stmt.members) {
|
|
643
|
+
if (!tsapi.isMethodDeclaration(m) || !m.body) continue;
|
|
644
|
+
const mdecos = tsapi.getDecorators ? tsapi.getDecorators(m) || [] : [];
|
|
645
|
+
const isRoute = mdecos.some((d) => {
|
|
646
|
+
const ex = d.expression;
|
|
647
|
+
const callee = tsapi.isCallExpression(ex) ? ex.expression : ex;
|
|
648
|
+
const n = callee && callee.getText(fileInfo.sf);
|
|
649
|
+
return n === 'Get' || n === 'Post' || n === 'Put' || n === 'Patch' || n === 'Delete' || n === 'Query'
|
|
650
|
+
// @Upload is a route like any other: on the wire it is a POST, and its
|
|
651
|
+
// RETURN TYPE is the completion's response contract. Leaving it out here
|
|
652
|
+
// meant an @Upload method's return type was never read, so the operation
|
|
653
|
+
// shipped with NO 200 schema and every generated client got an EMPTY
|
|
654
|
+
// response struct — the upload worked and the document it returns
|
|
655
|
+
// (url, variants, caption) was untyped and unreachable from the app.
|
|
656
|
+
|| n === 'Upload';
|
|
657
|
+
});
|
|
658
|
+
if (!isRoute) continue;
|
|
659
|
+
|
|
660
|
+
const fnName = m.name.getText(fileInfo.sf);
|
|
661
|
+
const out = [];
|
|
662
|
+
const visited = new Set([`${controllerPath}#${className || '<anon>'}.${fnName}`]);
|
|
663
|
+
collectFromBody(ctx, fileInfo, stmt, m.body, out, 0, visited);
|
|
664
|
+
ops[fnName] = out;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
} catch (e) {
|
|
668
|
+
if (e instanceof ThrowAnalysisError) return { error: e.message };
|
|
669
|
+
throw e;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
return { className, ops };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// buildThrowInjection renders the pinned recordThrows IIFEs — one per ANALYZED
|
|
676
|
+
// route method, INCLUDING empty sets. An explicit empty `recordThrows(.., [])`
|
|
677
|
+
// is what arms the runtime conformance guard for routes whose throws were all
|
|
678
|
+
// statically invisible (the routes most likely to be missing typing), and it
|
|
679
|
+
// distinguishes "analyzed, nothing visible" from "pre-analysis bundle". The
|
|
680
|
+
// IIFE shape is a cross-repo contract — the SDK (recordThrows), the worker
|
|
681
|
+
// guard, and `palbase build` all key off it.
|
|
682
|
+
function buildThrowInjection(className, ops) {
|
|
683
|
+
const fnNames = Object.keys(ops);
|
|
684
|
+
if (!className || fnNames.length === 0) return '';
|
|
685
|
+
const lines = [];
|
|
686
|
+
lines.push('');
|
|
687
|
+
lines.push('// --- AUTO-GENERATED: route throw bindings (inferred throw sites) ---');
|
|
688
|
+
for (const fn of fnNames) {
|
|
689
|
+
const descriptors = ops[fn]
|
|
690
|
+
.map((d) => `{name:${JSON.stringify(d.name)},code:${JSON.stringify(d.code)}}`)
|
|
691
|
+
.join(',');
|
|
692
|
+
lines.push(';(function(){ try { var __pb = require("@palbase/backend");');
|
|
693
|
+
lines.push(` __pb.recordThrows(${className}.prototype, ${JSON.stringify(fn)}, [${descriptors}]);`);
|
|
694
|
+
lines.push('} catch (e) {} })();');
|
|
695
|
+
}
|
|
696
|
+
lines.push('');
|
|
697
|
+
return lines.join('\n');
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* injectThrowBindings appends the recordThrows IIFEs to a controller's source.
|
|
702
|
+
* Best-effort by design: any unexpected analysis failure returns the source
|
|
703
|
+
* UNCHANGED (the routes just carry no `throws` — clients degrade to `.other`).
|
|
704
|
+
* The ONE exception: the defineError non-literal-args violation rethrows as
|
|
705
|
+
* ThrowAnalysisError so the stager fails the deploy loudly, naming the file.
|
|
706
|
+
*/
|
|
707
|
+
function injectThrowBindings(sourceText, controllerPath, deps) {
|
|
708
|
+
let analyzed;
|
|
709
|
+
try {
|
|
710
|
+
analyzed = analyzeThrows(sourceText, controllerPath, deps);
|
|
711
|
+
} catch {
|
|
712
|
+
return sourceText; // best-effort — never fail the stage on an analyzer bug
|
|
713
|
+
}
|
|
714
|
+
if (analyzed.error) {
|
|
715
|
+
throw new ThrowAnalysisError(analyzed.error);
|
|
716
|
+
}
|
|
717
|
+
const snippet = buildThrowInjection(analyzed.className, analyzed.ops);
|
|
718
|
+
if (!snippet) return sourceText;
|
|
719
|
+
return sourceText + '\n' + snippet + '\n';
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
module.exports = {
|
|
723
|
+
ThrowAnalysisError,
|
|
724
|
+
analyzeThrows,
|
|
725
|
+
injectThrowBindings,
|
|
726
|
+
};
|