lemon-core 5.0.4 → 5.0.6
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 +168 -136
- package/bin/lemon-fields-types.mjs +12 -0
- package/bin/lib/fields-types.mjs +629 -0
- package/dist/common/test-helper.js +6 -3
- package/dist/common/test-helper.js.map +1 -1
- package/dist/cores/aws/aws-sns-service.d.ts +6 -0
- package/dist/cores/aws/aws-sns-service.js +22 -3
- package/dist/cores/aws/aws-sns-service.js.map +1 -1
- package/dist/cores/aws/aws-sqs-service.js +5 -0
- package/dist/cores/aws/aws-sqs-service.js.map +1 -1
- package/dist/cores/config/config-service.js +3 -1
- package/dist/cores/config/config-service.js.map +1 -1
- package/dist/cores/lambda/lambda-alb-handler.js +5 -0
- package/dist/cores/lambda/lambda-alb-handler.js.map +1 -1
- package/dist/cores/lambda/lambda-handler.js +4 -1
- package/dist/cores/lambda/lambda-handler.js.map +1 -1
- package/dist/cores/lambda/lambda-notification-handler.js +4 -1
- package/dist/cores/lambda/lambda-notification-handler.js.map +1 -1
- package/dist/cores/lambda/lambda-sns-handler.d.ts +7 -1
- package/dist/cores/lambda/lambda-sns-handler.js +26 -3
- package/dist/cores/lambda/lambda-sns-handler.js.map +1 -1
- package/dist/cores/lambda/lambda-sqs-handler.d.ts +7 -1
- package/dist/cores/lambda/lambda-sqs-handler.js +26 -3
- package/dist/cores/lambda/lambda-sqs-handler.js.map +1 -1
- package/dist/cores/lambda/lambda-web-handler.js +63 -15
- package/dist/cores/lambda/lambda-web-handler.js.map +1 -1
- package/dist/cores/protocol/aws-lambda-helper.d.ts +13 -0
- package/dist/cores/protocol/aws-lambda-helper.js +64 -41
- package/dist/cores/protocol/aws-lambda-helper.js.map +1 -1
- package/dist/cores/protocol/local-transport.d.ts +135 -0
- package/dist/cores/protocol/local-transport.js +392 -0
- package/dist/cores/protocol/local-transport.js.map +1 -0
- package/dist/cores/protocol/protocol-relay.d.ts +123 -0
- package/dist/cores/protocol/protocol-relay.js +339 -0
- package/dist/cores/protocol/protocol-relay.js.map +1 -0
- package/dist/cores/protocol/protocol-service.d.ts +11 -4
- package/dist/cores/protocol/protocol-service.js +140 -36
- package/dist/cores/protocol/protocol-service.js.map +1 -1
- package/dist/cores/protocol/protocol-sign.d.ts +22 -0
- package/dist/cores/protocol/protocol-sign.js +30 -3
- package/dist/cores/protocol/protocol-sign.js.map +1 -1
- package/dist/engine/engine.js +35 -15
- package/dist/engine/engine.js.map +1 -1
- package/dist/esm/index.mjs +2 -0
- package/dist/extended/cores/abstract-controllers.d.ts +18 -4
- package/dist/extended/cores/abstract-controllers.js +59 -19
- package/dist/extended/cores/abstract-controllers.js.map +1 -1
- package/dist/extended/cores/abstract-services.d.ts +54 -4
- package/dist/extended/cores/abstract-services.js +159 -14
- package/dist/extended/cores/abstract-services.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/log-mask.d.ts +19 -0
- package/dist/lib/log-mask.js +55 -0
- package/dist/lib/log-mask.js.map +1 -0
- package/dist/lib/safe-json.d.ts +23 -0
- package/dist/lib/safe-json.js +78 -0
- package/dist/lib/safe-json.js.map +1 -0
- package/package.json +14 -4
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bin/lib/fields-types.mjs` (lemon-core)
|
|
3
|
+
* - check that one field name has one type across the stored models of one OpenSearch index.
|
|
4
|
+
* An index has one dynamic mapping, so the first model that writes `x` fixes the type of `x`;
|
|
5
|
+
* a later model that writes `x` with another type gets a 400 `mapper_parsing_exception`.
|
|
6
|
+
* - reads the declared TypeScript types only (`typescript` compiler API); nothing is compiled or run.
|
|
7
|
+
* - all logic of `bin/lemon-fields-types.mjs`: no `process.exit`; the printers are injectable, so the tests run offline.
|
|
8
|
+
* - imports node built-ins only; `typescript` is resolved at run time (the checked repo, `--fallback`, then lemon-core).
|
|
9
|
+
* - ported from lemon-templates-api `bin/lib/fields-types.mjs` (B10-5).
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { builtinModules, createRequire } from 'node:module';
|
|
14
|
+
|
|
15
|
+
//* v5 B10-5: `typescript` is not imported statically; `useTypeScript()` sets these two from `resolveTypeScript()`.
|
|
16
|
+
// the loaded `typescript` module (the one that built the current program).
|
|
17
|
+
let ts = null;
|
|
18
|
+
|
|
19
|
+
// type flags of that module, short.
|
|
20
|
+
let F = null;
|
|
21
|
+
|
|
22
|
+
// the normalized kinds that map to one OpenSearch field type.
|
|
23
|
+
export const SCALARS = ['string', 'number', 'boolean', 'date'];
|
|
24
|
+
|
|
25
|
+
// the kinds that take part in the conflict check (`any`, `mixed`, `dynamic`, `other(..)` do not).
|
|
26
|
+
export const TYPED = [...SCALARS, 'object'];
|
|
27
|
+
|
|
28
|
+
// default nesting depth of object fields (`a.b.c` is depth 2).
|
|
29
|
+
export const DEFAULT_DEEP = 2;
|
|
30
|
+
|
|
31
|
+
// the CoreModel shape of rule H (a declaration with all of these is a model).
|
|
32
|
+
export const CORE_SHAPE = ['ns', 'sid', 'uid', 'lock', 'next', 'createdAt'];
|
|
33
|
+
|
|
34
|
+
// help text for `--help`.
|
|
35
|
+
export const HELP = [
|
|
36
|
+
'Check that one field name has one type across the stored models of one OpenSearch index.',
|
|
37
|
+
'',
|
|
38
|
+
'usage: lemon-fields-types [options]',
|
|
39
|
+
' npx lemon-fields-types [options]',
|
|
40
|
+
' npm run fields:types -- [options]',
|
|
41
|
+
'',
|
|
42
|
+
' --root <dir> repo to check: reads <dir>/src/**/*.ts and <dir>/tsconfig.json (default: cwd)',
|
|
43
|
+
` --deep <n> nesting depth of object fields, 0-10 (default: ${DEFAULT_DEEP})`,
|
|
44
|
+
' --models R|RH R = models in a `$FIELD` object (default); RH = also every CoreModel-shaped type',
|
|
45
|
+
' --fail-on <level> hard | order | none (default: hard)',
|
|
46
|
+
' --json print the full report as JSON only',
|
|
47
|
+
' --with <report.json> merge another repo report (from --json) as the same index; repeatable',
|
|
48
|
+
' --fallback <dir> extra node_modules dir for packages the repo cannot resolve; repeatable',
|
|
49
|
+
' --help show this help',
|
|
50
|
+
'',
|
|
51
|
+
'typescript: from the checked repo (its devDependency) first, then each --fallback dir, then lemon-core itself.',
|
|
52
|
+
'',
|
|
53
|
+
'exit codes: 0 = no conflict at or above --fail-on; 1 = conflicts; 2 = bad arguments, no typescript, no TS program.',
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
// a bad argument, an unreadable report, or no TypeScript program (exit 2).
|
|
57
|
+
export class ArgError extends Error {
|
|
58
|
+
// always exit code 2.
|
|
59
|
+
constructor(message) {
|
|
60
|
+
super(message);
|
|
61
|
+
this.name = 'ArgError';
|
|
62
|
+
this.code = 2;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
//* v5 B10-5: `typescript` resolution.
|
|
67
|
+
// the place of the tool itself (the last place `resolveTypeScript` tries).
|
|
68
|
+
const SELF = import.meta.url;
|
|
69
|
+
|
|
70
|
+
// resolve and load `typescript`: `<root>`, then each `--fallback` node_modules dir, then the tool's own location.
|
|
71
|
+
// returns `{ ts, version, from: 'root' | 'fallback' | 'self' }`; throws `ArgError` (exit 2) when none resolves.
|
|
72
|
+
export const resolveTypeScript = (root, fallback = [], self = SELF) => {
|
|
73
|
+
const places = [
|
|
74
|
+
{ from: 'root', base: path.join(root, 'package.json') },
|
|
75
|
+
...fallback.map(fb => ({ from: 'fallback', base: path.join(fb, '..', 'package.json') })),
|
|
76
|
+
{ from: 'self', base: self },
|
|
77
|
+
];
|
|
78
|
+
for (const p of places) {
|
|
79
|
+
// node's own lookup from that place (its node_modules, then the parent dirs).
|
|
80
|
+
const req = createRequire(p.base);
|
|
81
|
+
let file;
|
|
82
|
+
try {
|
|
83
|
+
file = req.resolve('typescript');
|
|
84
|
+
} catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const mod = req(file);
|
|
88
|
+
return { ts: mod, version: String(mod.version), from: p.from };
|
|
89
|
+
}
|
|
90
|
+
throw new ArgError(
|
|
91
|
+
`cannot resolve typescript from --root ${root}, --fallback (${fallback.length} dirs), or lemon-core itself` +
|
|
92
|
+
' (add typescript to the devDependencies of the repo, or pass --fallback <node_modules>)',
|
|
93
|
+
);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// make `m` the module of the next program and of the type helpers below.
|
|
97
|
+
const useTypeScript = m => {
|
|
98
|
+
ts = m;
|
|
99
|
+
F = m.TypeFlags;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// options that take a value; `with` and `fallback` repeat.
|
|
103
|
+
const VALUES = ['root', 'deep', 'models', 'fail-on', 'with', 'fallback'];
|
|
104
|
+
|
|
105
|
+
// options that repeat (collected into an array).
|
|
106
|
+
const REPEAT = ['with', 'fallback'];
|
|
107
|
+
|
|
108
|
+
// options that never take a value.
|
|
109
|
+
const BOOLEANS = ['json', 'help'];
|
|
110
|
+
|
|
111
|
+
// parse argv strictly into `{ root, deep, models, failOn, json, help, with: [], fallback: [] }` (throws `ArgError`).
|
|
112
|
+
export const parseOptions = argv => {
|
|
113
|
+
const raw = { with: [], fallback: [] };
|
|
114
|
+
const errors = [];
|
|
115
|
+
for (let i = 0; i < argv.length; i++) {
|
|
116
|
+
const tok = argv[i];
|
|
117
|
+
// only `--key` and `--key=value`; no positionals.
|
|
118
|
+
if (!tok.startsWith('--')) {
|
|
119
|
+
errors.push(`unknown argument ${tok} (see --help)`);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const eq = tok.indexOf('=');
|
|
123
|
+
const key = eq >= 0 ? tok.slice(2, eq) : tok.slice(2);
|
|
124
|
+
// a boolean flag takes no value.
|
|
125
|
+
if (BOOLEANS.includes(key) && eq < 0) {
|
|
126
|
+
raw[key] = true;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (!VALUES.includes(key)) {
|
|
130
|
+
errors.push(`unknown option ${tok} (see --help)`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
// the value is after `=`, or the next token (which must not be an option).
|
|
134
|
+
let value = eq >= 0 ? tok.slice(eq + 1) : argv[i + 1];
|
|
135
|
+
if (eq < 0) {
|
|
136
|
+
if (value === undefined || value.startsWith('--')) value = '';
|
|
137
|
+
else i++;
|
|
138
|
+
}
|
|
139
|
+
if (value === '') errors.push(`--${key} needs a value`);
|
|
140
|
+
else if (REPEAT.includes(key)) raw[key].push(value);
|
|
141
|
+
else raw[key] = value;
|
|
142
|
+
}
|
|
143
|
+
// `--help` wins over argument errors.
|
|
144
|
+
if (raw.help) return { help: true };
|
|
145
|
+
if (errors.length) throw new ArgError(errors[0]);
|
|
146
|
+
// check each value.
|
|
147
|
+
const deep = raw.deep === undefined ? DEFAULT_DEEP : Number(raw.deep);
|
|
148
|
+
if (!Number.isInteger(deep) || deep < 0 || deep > 10) throw new ArgError(`--deep must be 0-10 (got: ${raw.deep})`);
|
|
149
|
+
const models = raw.models ?? 'R';
|
|
150
|
+
if (!['R', 'RH'].includes(models)) throw new ArgError(`--models must be R or RH (got: ${models})`);
|
|
151
|
+
const failOn = raw['fail-on'] ?? 'hard';
|
|
152
|
+
if (!['hard', 'order', 'none'].includes(failOn)) {
|
|
153
|
+
throw new ArgError(`--fail-on must be hard, order, or none (got: ${failOn})`);
|
|
154
|
+
}
|
|
155
|
+
const root = path.resolve(raw.root ?? '.');
|
|
156
|
+
return {
|
|
157
|
+
root,
|
|
158
|
+
deep,
|
|
159
|
+
models,
|
|
160
|
+
failOn,
|
|
161
|
+
json: !!raw.json,
|
|
162
|
+
with: raw.with,
|
|
163
|
+
fallback: raw.fallback.map(f => path.resolve(f)),
|
|
164
|
+
};
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// `src/**/*.ts` of the repo, without `*.spec.ts` / `*.test.ts` and `node_modules` (sorted).
|
|
168
|
+
export const listSources = root => {
|
|
169
|
+
const src = path.join(root, 'src');
|
|
170
|
+
if (!fs.existsSync(src) || !fs.statSync(src).isDirectory()) throw new ArgError(`no src/ directory in ${root}`);
|
|
171
|
+
const files = [];
|
|
172
|
+
// depth-first walk.
|
|
173
|
+
const walk = dir => {
|
|
174
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
175
|
+
const p = path.join(dir, e.name);
|
|
176
|
+
if (e.isDirectory()) {
|
|
177
|
+
if (e.name !== 'node_modules') walk(p);
|
|
178
|
+
} else if (/\.ts$/.test(e.name) && !/\.(spec|test)\.ts$/.test(e.name)) files.push(p);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
walk(src);
|
|
182
|
+
if (!files.length) throw new ArgError(`no TypeScript files in ${src}`);
|
|
183
|
+
return files.sort();
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// compiler options of `<root>/tsconfig.json` (or defaults), with null checks on so `null`/`undefined` stay visible.
|
|
187
|
+
export const compilerOptions = root => {
|
|
188
|
+
const file = path.join(root, 'tsconfig.json');
|
|
189
|
+
let base = {};
|
|
190
|
+
if (fs.existsSync(file)) {
|
|
191
|
+
const read = ts.readConfigFile(file, ts.sys.readFile);
|
|
192
|
+
if (read.error) {
|
|
193
|
+
const msg = ts.flattenDiagnosticMessageText(read.error.messageText, ' ');
|
|
194
|
+
throw new ArgError(`cannot read ${file} (${msg})`);
|
|
195
|
+
}
|
|
196
|
+
// `include`/`files` errors (no inputs) do not matter: the file list is ours.
|
|
197
|
+
base = ts.parseJsonConfigFileContent(read.config, ts.sys, root).options;
|
|
198
|
+
}
|
|
199
|
+
// no emit, no build info, no transformer plugins.
|
|
200
|
+
return {
|
|
201
|
+
...base,
|
|
202
|
+
noEmit: true,
|
|
203
|
+
skipLibCheck: true,
|
|
204
|
+
strictNullChecks: true,
|
|
205
|
+
incremental: false,
|
|
206
|
+
composite: false,
|
|
207
|
+
tsBuildInfoFile: undefined,
|
|
208
|
+
plugins: undefined,
|
|
209
|
+
};
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// `{ file, line }` of a declaration: path relative to `root`, or `<package>:<file name>` for a package file.
|
|
213
|
+
export const locate = (decl, root) => {
|
|
214
|
+
if (!decl) return { file: '?', line: 0 };
|
|
215
|
+
const sf = decl.getSourceFile();
|
|
216
|
+
const line = sf.getLineAndCharacterOfPosition(decl.getStart()).line + 1;
|
|
217
|
+
const rel = path.relative(root, sf.fileName);
|
|
218
|
+
// a package file: name the package (`@scope/name` or `name`) after the last `node_modules/`.
|
|
219
|
+
const nm = sf.fileName.lastIndexOf('/node_modules/');
|
|
220
|
+
if (nm >= 0) {
|
|
221
|
+
const parts = sf.fileName.slice(nm + 14).split('/');
|
|
222
|
+
const pkg = parts[0].startsWith('@') ? `${parts[0]}/${parts[1]}` : parts[0];
|
|
223
|
+
return { file: `${pkg}:${path.basename(sf.fileName)}`, line };
|
|
224
|
+
}
|
|
225
|
+
// outside the repo (ex: a `--fallback` source tree): the file name only.
|
|
226
|
+
return { file: rel.startsWith('..') ? path.basename(sf.fileName) : rel, line };
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// the type of the `$FIELD` entry call: `keys<T>()`, `fieldKeys.x<T>()`, or `fieldsOf(Class)`; else null.
|
|
230
|
+
const typeOfFieldsCall = (checker, call) => {
|
|
231
|
+
const callee = call.expression;
|
|
232
|
+
const name = ts.isIdentifier(callee)
|
|
233
|
+
? callee.text
|
|
234
|
+
: ts.isPropertyAccessExpression(callee)
|
|
235
|
+
? callee.expression.getText()
|
|
236
|
+
: '';
|
|
237
|
+
// `keys<T>()` and `fieldKeys.x<T>()`: the type argument.
|
|
238
|
+
if ((name === 'keys' || name === 'fieldKeys') && call.typeArguments?.length) {
|
|
239
|
+
return checker.getTypeFromTypeNode(call.typeArguments[0]);
|
|
240
|
+
}
|
|
241
|
+
// `fieldsOf(Class)`: the instance type of the class.
|
|
242
|
+
if (ts.isIdentifier(callee) && callee.text === 'fieldsOf' && call.arguments[0]) {
|
|
243
|
+
const s = checker.getSymbolAtLocation(call.arguments[0]);
|
|
244
|
+
const target = s && s.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(s) : s;
|
|
245
|
+
return target ? checker.getDeclaredTypeOfSymbol(target) : null;
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
// build the TS program of `root` and collect its models.
|
|
251
|
+
// opts: `{ root, models: 'R'|'RH', fallback: [node_modules dirs] }`.
|
|
252
|
+
// rule R: a model named in a `$FIELD = { k: ... }` object literal (what the service stores and syncs).
|
|
253
|
+
// rule H: an interface or class with the CoreModel shape (views and heads too).
|
|
254
|
+
// returns `{ root, files, checker, models, found, fieldEntries, unresolved, typescript }`; `models` is the checked set.
|
|
255
|
+
export const collectModels = ({ root, models: rule = 'R', fallback = [] } = {}) => {
|
|
256
|
+
const files = listSources(root);
|
|
257
|
+
// v5 B10-5: the `typescript` of the checked repo (or a fallback); `typescript` in the result says which.
|
|
258
|
+
const resolved = resolveTypeScript(root, fallback);
|
|
259
|
+
useTypeScript(resolved.ts);
|
|
260
|
+
const typescript = { version: resolved.version, from: resolved.from };
|
|
261
|
+
const options = compilerOptions(root);
|
|
262
|
+
const unresolved = new Set();
|
|
263
|
+
let program;
|
|
264
|
+
try {
|
|
265
|
+
// module resolution: the repo first, then each `--fallback` dir.
|
|
266
|
+
const host = ts.createCompilerHost(options);
|
|
267
|
+
host.resolveModuleNames = (names, containing) =>
|
|
268
|
+
names.map(name => {
|
|
269
|
+
const own = ts.resolveModuleName(name, containing, options, ts.sys).resolvedModule;
|
|
270
|
+
if (own) return own;
|
|
271
|
+
for (const fb of fallback) {
|
|
272
|
+
const r = ts.resolveModuleName(name, path.join(fb, '..', 'index.ts'), options, ts.sys);
|
|
273
|
+
if (r.resolvedModule) return r.resolvedModule;
|
|
274
|
+
}
|
|
275
|
+
// node built-ins are ambient (`@types/node`); relative misses are not packages.
|
|
276
|
+
const bare = name.replace(/^node:/, '');
|
|
277
|
+
if (!name.startsWith('.') && !name.startsWith('node:') && !builtinModules.includes(bare)) {
|
|
278
|
+
unresolved.add(name);
|
|
279
|
+
}
|
|
280
|
+
return undefined;
|
|
281
|
+
});
|
|
282
|
+
program = ts.createProgram(files, options, host);
|
|
283
|
+
} catch (e) {
|
|
284
|
+
throw new ArgError(`cannot create the TypeScript program of ${root} (${e?.message ?? e})`);
|
|
285
|
+
}
|
|
286
|
+
const checker = program.getTypeChecker();
|
|
287
|
+
const byKey = new Map();
|
|
288
|
+
// add (or tag) one model; the key is the declaration site, so one type found twice is one model.
|
|
289
|
+
const add = (type, name, site, ruleName) => {
|
|
290
|
+
const sym = type.aliasSymbol ?? type.symbol;
|
|
291
|
+
const decl = sym?.declarations?.[0];
|
|
292
|
+
const at = decl ? locate(decl, root) : site;
|
|
293
|
+
const key = `${at.file}:${at.line}#${sym?.name ?? name}`;
|
|
294
|
+
if (!byKey.has(key)) {
|
|
295
|
+
byKey.set(key, {
|
|
296
|
+
name: sym?.name ?? name,
|
|
297
|
+
file: at.file,
|
|
298
|
+
line: at.line,
|
|
299
|
+
rules: [],
|
|
300
|
+
type,
|
|
301
|
+
checker,
|
|
302
|
+
root,
|
|
303
|
+
ts,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
const m = byKey.get(key);
|
|
307
|
+
if (!m.rules.includes(ruleName)) m.rules.push(ruleName);
|
|
308
|
+
};
|
|
309
|
+
let fieldEntries = 0;
|
|
310
|
+
const own = new Set(files.map(f => path.resolve(f)));
|
|
311
|
+
for (const sf of program.getSourceFiles()) {
|
|
312
|
+
if (!own.has(path.resolve(sf.fileName))) continue;
|
|
313
|
+
// walk every node of the file.
|
|
314
|
+
const visit = node => {
|
|
315
|
+
// rule R: each property of `$FIELD = { k: <... fields call ...> }`.
|
|
316
|
+
if (
|
|
317
|
+
ts.isVariableDeclaration(node) &&
|
|
318
|
+
node.name.getText() === '$FIELD' &&
|
|
319
|
+
node.initializer &&
|
|
320
|
+
ts.isObjectLiteralExpression(node.initializer)
|
|
321
|
+
) {
|
|
322
|
+
for (const prop of node.initializer.properties) {
|
|
323
|
+
if (!ts.isPropertyAssignment(prop)) continue;
|
|
324
|
+
// the first fields call inside the entry (ex: inside `filterFields(...)`).
|
|
325
|
+
const find = n =>
|
|
326
|
+
(ts.isCallExpression(n) && typeOfFieldsCall(checker, n)) || ts.forEachChild(n, find);
|
|
327
|
+
const t = find(prop.initializer);
|
|
328
|
+
if (t) {
|
|
329
|
+
fieldEntries++;
|
|
330
|
+
add(t, prop.name.getText(), locate(prop, root), 'R');
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// rule H: an interface or class with the CoreModel shape.
|
|
335
|
+
if ((ts.isInterfaceDeclaration(node) || ts.isClassDeclaration(node)) && node.name) {
|
|
336
|
+
const t = checker.getDeclaredTypeOfSymbol(checker.getSymbolAtLocation(node.name));
|
|
337
|
+
if (CORE_SHAPE.every(k => checker.getPropertyOfType(t, k)))
|
|
338
|
+
add(t, node.name.text, locate(node, root), 'H');
|
|
339
|
+
}
|
|
340
|
+
ts.forEachChild(node, visit);
|
|
341
|
+
};
|
|
342
|
+
visit(sf);
|
|
343
|
+
}
|
|
344
|
+
const found = [...byKey.values()];
|
|
345
|
+
// R by default; a repo with no `$FIELD` entry falls back to H, so it is not an empty pass.
|
|
346
|
+
const checked = found.filter(m => rule === 'RH' || m.rules.includes('R') || !fieldEntries);
|
|
347
|
+
return {
|
|
348
|
+
root,
|
|
349
|
+
files,
|
|
350
|
+
checker,
|
|
351
|
+
models: checked,
|
|
352
|
+
found,
|
|
353
|
+
fieldEntries,
|
|
354
|
+
unresolved: [...unresolved].sort(),
|
|
355
|
+
typescript,
|
|
356
|
+
};
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
// normalize one TS type to the kind OpenSearch maps it to:
|
|
360
|
+
// string | number | boolean | date | object | any | mixed | other(<type>).
|
|
361
|
+
export const normalizeType = (checker, type, depth = 0) => {
|
|
362
|
+
if (depth > 8) return 'any';
|
|
363
|
+
// no type information.
|
|
364
|
+
if (type.flags & (F.Any | F.Unknown | F.Never)) return 'any';
|
|
365
|
+
// a type parameter: its constraint.
|
|
366
|
+
if (type.flags & F.TypeParameter) {
|
|
367
|
+
const c = checker.getBaseConstraintOfType(type);
|
|
368
|
+
return c && c !== type ? normalizeType(checker, c, depth + 1) : 'any';
|
|
369
|
+
}
|
|
370
|
+
// a union: drop null/undefined/void; one kind left is that kind, more is mixed.
|
|
371
|
+
if (type.isUnion()) {
|
|
372
|
+
const kinds = new Set(
|
|
373
|
+
type.types
|
|
374
|
+
.filter(x => !(x.flags & (F.Null | F.Undefined | F.Void)))
|
|
375
|
+
.map(x => normalizeType(checker, x, depth + 1)),
|
|
376
|
+
);
|
|
377
|
+
const parts = [...kinds];
|
|
378
|
+
return parts.includes('any') || !parts.length ? 'any' : parts.length === 1 ? parts[0] : 'mixed';
|
|
379
|
+
}
|
|
380
|
+
// scalars, literals and enums included.
|
|
381
|
+
if (type.flags & F.StringLike) return 'string';
|
|
382
|
+
if (type.flags & (F.NumberLike | F.BigIntLike)) return 'number';
|
|
383
|
+
if (type.flags & F.BooleanLike) return 'boolean';
|
|
384
|
+
// an array or tuple: the element kind (OpenSearch has no array type).
|
|
385
|
+
if (checker.isArrayType(type) || checker.isTupleType(type)) {
|
|
386
|
+
const parts = [...new Set(checker.getTypeArguments(type).map(x => normalizeType(checker, x, depth + 1)))];
|
|
387
|
+
return parts.length === 1 ? parts[0] : parts.includes('any') || !parts.length ? 'any' : 'mixed';
|
|
388
|
+
}
|
|
389
|
+
// `Date` is a date field, not an object.
|
|
390
|
+
if (type.symbol?.name === 'Date') return 'date';
|
|
391
|
+
// objects, intersections, and the `object` keyword.
|
|
392
|
+
if (type.flags & (F.Object | F.NonPrimitive) || type.isIntersection()) return 'object';
|
|
393
|
+
return `other(${checker.typeToString(type)})`;
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
// a stored top-level name: what `filterFields` keeps and `$U.cleanup` does not drop (`_*`, `$*`).
|
|
397
|
+
export const isStored = name => name !== '_id' && /^[a-z_][a-zA-Z_]+/.test(name) && !name.startsWith('_');
|
|
398
|
+
|
|
399
|
+
// the properties of an object field to flatten (`{ dyn: true }` for an index signature).
|
|
400
|
+
const objectProps = (checker, type) => {
|
|
401
|
+
const members = type.isUnion() ? type.types.filter(x => !(x.flags & (F.Null | F.Undefined))) : [type];
|
|
402
|
+
const out = [];
|
|
403
|
+
for (const m0 of members) {
|
|
404
|
+
// an array of objects: its element.
|
|
405
|
+
const m = checker.isArrayType(m0) ? checker.getTypeArguments(m0)[0] : m0;
|
|
406
|
+
if (!m || !(m.flags & F.Object) || m.symbol?.name === 'Date') continue;
|
|
407
|
+
if (checker.getIndexInfosOfType(m).length) out.push({ dyn: true });
|
|
408
|
+
for (const p of checker.getPropertiesOfType(m)) out.push({ p });
|
|
409
|
+
}
|
|
410
|
+
return out;
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
// the fields of one model, flattened to dotted paths up to `deep`: `[{ name, type, file, line }]`.
|
|
414
|
+
// an index signature gives `<path>.*` with type `dynamic` (dynamic keys are not checked).
|
|
415
|
+
// `model` comes from `collectModels` (it carries `type`, `checker`, and `root`).
|
|
416
|
+
export const flattenFields = (model, deep = DEFAULT_DEEP) => {
|
|
417
|
+
const { checker, root } = model;
|
|
418
|
+
// v5 B10-5: the type helpers use the `typescript` that built this model's checker.
|
|
419
|
+
if (model.ts) useTypeScript(model.ts);
|
|
420
|
+
const out = [];
|
|
421
|
+
// one level: top-level properties, or the members of an object field.
|
|
422
|
+
const walk = (type, prefix, depth, parent) => {
|
|
423
|
+
const props = prefix ? objectProps(checker, type) : checker.getPropertiesOfType(type).map(p => ({ p }));
|
|
424
|
+
for (const { p, dyn } of props) {
|
|
425
|
+
if (dyn) {
|
|
426
|
+
out.push({ name: `${prefix}.*`, type: 'dynamic', ...parent });
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (!prefix && !isStored(p.name)) continue;
|
|
430
|
+
const name = prefix ? `${prefix}.${p.name}` : p.name;
|
|
431
|
+
const decl = p.valueDeclaration ?? p.declarations?.[0];
|
|
432
|
+
const t = decl ? checker.getTypeOfSymbolAtLocation(p, decl) : checker.getTypeOfSymbol(p);
|
|
433
|
+
let kind = normalizeType(checker, t);
|
|
434
|
+
// a generic default `any` (ex: `S extends string = any`): the declared type node instead.
|
|
435
|
+
if (kind === 'any' && decl?.type) kind = normalizeType(checker, checker.getTypeFromTypeNode(decl.type));
|
|
436
|
+
const at = locate(decl, root);
|
|
437
|
+
out.push({ name, type: kind, ...at });
|
|
438
|
+
if (kind === 'object' && depth < deep) walk(t, name, depth + 1, at);
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
walk(model.type, '', 0, null);
|
|
442
|
+
return out;
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
// severity of two different kinds (from the local OpenSearch 2.17 probe):
|
|
446
|
+
// hard = the second write fails in both orders; order = it fails only when the stricter kind is mapped first.
|
|
447
|
+
export const severityOf = (a, b) => {
|
|
448
|
+
// object vs a scalar fails both ways.
|
|
449
|
+
if (a === 'object' || b === 'object') return 'hard';
|
|
450
|
+
const pair = [a, b].sort().join('|');
|
|
451
|
+
// boolean vs number (and boolean vs date) fails both ways.
|
|
452
|
+
if (pair === 'boolean|number' || pair === 'boolean|date') return 'hard';
|
|
453
|
+
// string|number, string|boolean, date|string, date|number: order-dependent.
|
|
454
|
+
return 'order';
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
// `<Model>` or `<repo>:<Model>` of a declaration.
|
|
458
|
+
const label = d => (d.repo ? `${d.repo}:${d.model}` : d.model);
|
|
459
|
+
|
|
460
|
+
// find the conflicts in models with flattened fields: `models = [{ name, repo?, fields: [{ name, type, file, line }] }]`.
|
|
461
|
+
// returns `{ conflicts: [{ name, severity, decls: [{ model, type, file, line }] }], any, mixed, other, unresolved }`.
|
|
462
|
+
export const findConflicts = (models, unresolved = []) => {
|
|
463
|
+
const byName = new Map();
|
|
464
|
+
const any = [];
|
|
465
|
+
const mixed = [];
|
|
466
|
+
const other = [];
|
|
467
|
+
// group declarations by field path; keep the untyped ones apart.
|
|
468
|
+
for (const m of models) {
|
|
469
|
+
for (const f of m.fields || []) {
|
|
470
|
+
const d = { model: m.name, type: f.type, file: f.file, line: f.line, ...(m.repo ? { repo: m.repo } : {}) };
|
|
471
|
+
if (f.type === 'dynamic') continue;
|
|
472
|
+
if (f.type === 'any') any.push({ name: f.name, ...d });
|
|
473
|
+
else if (f.type === 'mixed') mixed.push({ name: f.name, ...d });
|
|
474
|
+
else if (!TYPED.includes(f.type)) other.push({ name: f.name, ...d });
|
|
475
|
+
else {
|
|
476
|
+
if (!byName.has(f.name)) byName.set(f.name, []);
|
|
477
|
+
byName.get(f.name).push(d);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
const conflicts = [];
|
|
482
|
+
// a conflict: two or more kinds under one name; hard if any pair of kinds is hard.
|
|
483
|
+
for (const [name, decls] of byName) {
|
|
484
|
+
const kinds = [...new Set(decls.map(d => d.type))];
|
|
485
|
+
if (kinds.length < 2) continue;
|
|
486
|
+
let severity = 'order';
|
|
487
|
+
for (let i = 0; i < kinds.length; i++) {
|
|
488
|
+
for (let j = i + 1; j < kinds.length; j++) if (severityOf(kinds[i], kinds[j]) === 'hard') severity = 'hard';
|
|
489
|
+
}
|
|
490
|
+
conflicts.push({ name, severity, decls });
|
|
491
|
+
}
|
|
492
|
+
// hard first, then by name.
|
|
493
|
+
conflicts.sort((a, b) =>
|
|
494
|
+
a.severity === b.severity ? (a.name < b.name ? -1 : a.name > b.name ? 1 : 0) : a.severity === 'hard' ? -1 : 1,
|
|
495
|
+
);
|
|
496
|
+
return { conflicts, any, mixed, other, unresolved: [...unresolved] };
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
// distinct field paths of the models (without the `dynamic` `.*` paths).
|
|
500
|
+
const countFields = models => {
|
|
501
|
+
const names = new Set();
|
|
502
|
+
for (const m of models) for (const f of m.fields || []) if (f.type !== 'dynamic') names.add(f.name);
|
|
503
|
+
return names.size;
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
// the report of one repo: collect, flatten, and check.
|
|
507
|
+
// opts: `{ root, deep, models, fallback }`; returns a JSON-safe object (the `--json` output).
|
|
508
|
+
export const checkRepo = ({ root, deep = DEFAULT_DEEP, models: rule = 'R', fallback = [] }) => {
|
|
509
|
+
const c = collectModels({ root, models: rule, fallback });
|
|
510
|
+
// flatten each checked model; drop the TS objects so the report is plain JSON.
|
|
511
|
+
const models = c.models.map(m => ({
|
|
512
|
+
name: m.name,
|
|
513
|
+
file: m.file,
|
|
514
|
+
line: m.line,
|
|
515
|
+
rules: m.rules,
|
|
516
|
+
fields: flattenFields(m, deep),
|
|
517
|
+
}));
|
|
518
|
+
const found = findConflicts(models, c.unresolved);
|
|
519
|
+
return {
|
|
520
|
+
repo: path.basename(root),
|
|
521
|
+
deep,
|
|
522
|
+
rule,
|
|
523
|
+
typescript: c.typescript,
|
|
524
|
+
files: c.files.length,
|
|
525
|
+
fieldEntries: c.fieldEntries,
|
|
526
|
+
modelsFound: c.found.length,
|
|
527
|
+
fields: countFields(models),
|
|
528
|
+
models,
|
|
529
|
+
...found,
|
|
530
|
+
};
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
// merge repo reports (from `checkRepo` or `--json`) as one index: same name, other type is a conflict.
|
|
534
|
+
export const mergeReports = reports => {
|
|
535
|
+
const repos = reports.map(r => r.repo);
|
|
536
|
+
// tag each model with its repo so the lines show where it is.
|
|
537
|
+
const models = reports.flatMap(r => (r.models || []).map(m => ({ ...m, repo: m.repo ?? r.repo })));
|
|
538
|
+
const unresolved = [...new Set(reports.flatMap(r => r.unresolved || []))].sort();
|
|
539
|
+
const found = findConflicts(models, unresolved);
|
|
540
|
+
return {
|
|
541
|
+
repo: repos.join('+'),
|
|
542
|
+
repos,
|
|
543
|
+
deep: reports[0]?.deep ?? DEFAULT_DEEP,
|
|
544
|
+
rule: reports[0]?.rule ?? 'R',
|
|
545
|
+
// v5 B10-5: the `typescript` of the first report (this repo).
|
|
546
|
+
typescript: reports[0]?.typescript ?? null,
|
|
547
|
+
files: reports.reduce((s, r) => s + (r.files || 0), 0),
|
|
548
|
+
fieldEntries: reports.reduce((s, r) => s + (r.fieldEntries || 0), 0),
|
|
549
|
+
modelsFound: reports.reduce((s, r) => s + (r.modelsFound || 0), 0),
|
|
550
|
+
fields: countFields(models),
|
|
551
|
+
models,
|
|
552
|
+
...found,
|
|
553
|
+
};
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
// check the shape of a report read from `--with` (throws `ArgError`).
|
|
557
|
+
export const readReport = file => {
|
|
558
|
+
let value;
|
|
559
|
+
try {
|
|
560
|
+
value = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
561
|
+
} catch (e) {
|
|
562
|
+
throw new ArgError(`cannot read --with ${file} (${e.code || e.message})`);
|
|
563
|
+
}
|
|
564
|
+
// the fields `mergeReports` needs.
|
|
565
|
+
const ok =
|
|
566
|
+
value &&
|
|
567
|
+
typeof value === 'object' &&
|
|
568
|
+
typeof value.repo === 'string' &&
|
|
569
|
+
Array.isArray(value.models) &&
|
|
570
|
+
value.models.every(m => m && typeof m.name === 'string' && Array.isArray(m.fields));
|
|
571
|
+
if (!ok) throw new ArgError(`--with ${file} is not a fields-types report (run with --json)`);
|
|
572
|
+
return value;
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
// the most models listed per kind in one conflict line.
|
|
576
|
+
const MAX_LIST = 3;
|
|
577
|
+
|
|
578
|
+
// one conflict line: ` - <sev> <name>: <type>[<Model>(<file>:<line>), ...] vs <type>[...]`.
|
|
579
|
+
export const conflictLine = c => {
|
|
580
|
+
const kinds = [...new Set(c.decls.map(d => d.type))];
|
|
581
|
+
const parts = kinds.map(k => {
|
|
582
|
+
const at = [...new Set(c.decls.filter(d => d.type === k).map(d => `${label(d)}(${d.file}:${d.line})`))];
|
|
583
|
+
const more = at.length > MAX_LIST ? `, +${at.length - MAX_LIST}` : '';
|
|
584
|
+
return `${k}[${at.slice(0, MAX_LIST).join(', ')}${more}]`;
|
|
585
|
+
});
|
|
586
|
+
return ` - ${c.severity} ${c.name}: ${parts.join(' vs ')}`;
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
// the text output: one summary line, then one line per conflict.
|
|
590
|
+
export const formatSummary = report => {
|
|
591
|
+
const hard = report.conflicts.filter(c => c.severity === 'hard').length;
|
|
592
|
+
const order = report.conflicts.length - hard;
|
|
593
|
+
const head =
|
|
594
|
+
`fields-types repo=${report.repo} models=${report.models.length} fields=${report.fields}` +
|
|
595
|
+
` conflicts=${report.conflicts.length} (hard=${hard} order=${order})` +
|
|
596
|
+
` any=${report.any.length} mixed=${report.mixed.length} unresolved=${report.unresolved.length}` +
|
|
597
|
+
` ts=${report.typescript?.version ?? '?'}`;
|
|
598
|
+
return [head, ...report.conflicts.map(conflictLine)].join('\n');
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
// exit code for `--fail-on`: 1 when a conflict at or above the level exists.
|
|
602
|
+
export const exitCode = (report, failOn = 'hard') => {
|
|
603
|
+
if (failOn === 'none') return 0;
|
|
604
|
+
const hit = failOn === 'hard' ? report.conflicts.some(c => c.severity === 'hard') : report.conflicts.length > 0;
|
|
605
|
+
return hit ? 1 : 0;
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
// run the check; returns the exit code. deps: `{ log, error }` (both optional).
|
|
609
|
+
export const run = (argv, deps = {}) => {
|
|
610
|
+
const log = deps.log || (s => console.log(s));
|
|
611
|
+
const error = deps.error || (s => console.error(s));
|
|
612
|
+
try {
|
|
613
|
+
const opts = parseOptions(argv);
|
|
614
|
+
if (opts.help) {
|
|
615
|
+
log(HELP.join('\n'));
|
|
616
|
+
return 0;
|
|
617
|
+
}
|
|
618
|
+
// read the `--with` reports first, so a bad file stops before the TS program is built.
|
|
619
|
+
const others = opts.with.map(readReport);
|
|
620
|
+
const own = checkRepo(opts);
|
|
621
|
+
const report = others.length ? mergeReports([own, ...others]) : own;
|
|
622
|
+
log(opts.json ? JSON.stringify(report, null, 2) : formatSummary(report));
|
|
623
|
+
return exitCode(report, opts.failOn);
|
|
624
|
+
} catch (e) {
|
|
625
|
+
// argument and program errors exit 2; anything else is a bug and exits 2 too (no result).
|
|
626
|
+
error(`ERROR: ${e?.message ?? e}`);
|
|
627
|
+
return 2;
|
|
628
|
+
}
|
|
629
|
+
};
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
13
|
exports.asErrorPayload = exports.waited = exports.marshal = exports.environ = exports._it = exports.expect2 = exports.onlyDefined = exports.NUL404 = exports.GETERR$ = exports.GETERR = void 0;
|
|
14
|
+
const safe_json_1 = require("../lib/safe-json"); //* v5 B10-2: circular-safe stringify for error text (internal).
|
|
14
15
|
// Keep test runner access indirect. This module is exported from the public package API,
|
|
15
16
|
// so importing Vitest here would leak a devDependency into runtime consumers.
|
|
16
17
|
const $expect = () => globalThis?.expect;
|
|
@@ -29,7 +30,8 @@ const GETERR = (e) => {
|
|
|
29
30
|
const errors = Array.isArray(e.errors) ? e.errors.map(exports.GETERR) : [];
|
|
30
31
|
return [`${e.message}`, ...errors].filter(_ => _).join('\n');
|
|
31
32
|
}
|
|
32
|
-
|
|
33
|
+
//* v5 B10-2: a circular object (e.g. http response) must not throw here.
|
|
34
|
+
return e && typeof e == 'object' ? (0, safe_json_1.safeStringify)(e) : `${e}`;
|
|
33
35
|
};
|
|
34
36
|
exports.GETERR = GETERR;
|
|
35
37
|
/**
|
|
@@ -183,7 +185,8 @@ exports.waited = waited;
|
|
|
183
185
|
const asErrorPayload = (e, data) => {
|
|
184
186
|
const _msg = (e) => {
|
|
185
187
|
const m = (e && (e.message || e.statusMessage)) || e;
|
|
186
|
-
|
|
188
|
+
//* v5 B10-2: circular-safe, so the report is not lost.
|
|
189
|
+
return typeof m == 'object' ? (0, safe_json_1.safeStringify)(m) : `${m}`;
|
|
187
190
|
};
|
|
188
191
|
const _pack = (e, depth = 0) => {
|
|
189
192
|
if (e === undefined || e === null)
|
|
@@ -195,7 +198,7 @@ const asErrorPayload = (e, data) => {
|
|
|
195
198
|
? e
|
|
196
199
|
: e instanceof Error
|
|
197
200
|
? `${e.message}`
|
|
198
|
-
:
|
|
201
|
+
: (0, safe_json_1.safeStringify)(e); //* v5 B10-2: circular-safe (e.g. `req` <-> `res`).
|
|
199
202
|
// make sure of no infinite recursion.
|
|
200
203
|
if (depth > 5)
|
|
201
204
|
return { stack, error };
|