@ultimat3/cli 2.0.0 → 4.0.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/CLAUDE.md +109 -13
- package/README.md +1 -0
- package/package.json +24 -24
- package/src/budgets.ts +31 -8
- package/src/cmd-db-branch.ts +6 -2
- package/src/cmd-db.ts +138 -10
- package/src/cmd-deploy.ts +42 -14
- package/src/cmd-dev.ts +9 -2
- package/src/cmd-docs.ts +7 -3
- package/src/cmd-doctor.ts +16 -7
- package/src/cmd-fix.ts +15 -3
- package/src/cmd-generate.ts +29 -4
- package/src/cmd-help.ts +25 -4
- package/src/cmd-i18n.ts +8 -5
- package/src/cmd-jobs.ts +6 -5
- package/src/cmd-mcp.ts +16 -12
- package/src/cmd-new.ts +10 -14
- package/src/cmd-planned.ts +13 -0
- package/src/cmd-policy.ts +8 -6
- package/src/cmd-registries.ts +7 -6
- package/src/cmd-routes.ts +27 -4
- package/src/cmd-secrets.ts +6 -6
- package/src/cmd-test.ts +14 -3
- package/src/cmd-verify.ts +80 -10
- package/src/command.ts +10 -2
- package/src/db-branch.ts +18 -0
- package/src/db-generate.ts +38 -6
- package/src/db-seed.ts +294 -0
- package/src/dev-assets.ts +22 -3
- package/src/dev-cache.ts +9 -9
- package/src/dev-render.ts +6 -1
- package/src/dev-roles.ts +5 -3
- package/src/dev-runtime.ts +2 -2
- package/src/dev-storage.ts +6 -4
- package/src/dev-traces.ts +26 -4
- package/src/dispatch.ts +33 -4
- package/src/drift.ts +41 -1
- package/src/error-catalog.ts +1 -0
- package/src/error-codes.ts +11 -0
- package/src/error-contract.ts +31 -4
- package/src/exec.ts +42 -8
- package/src/fix-command.ts +9 -2
- package/src/fix-imports.ts +118 -0
- package/src/fix-scan.ts +251 -0
- package/src/flag-number.ts +11 -0
- package/src/flag-reads.ts +114 -0
- package/src/i18n-audit.ts +2 -1
- package/src/index.ts +19 -5
- package/src/jobs-drain.ts +6 -1
- package/src/mcp-errors.ts +13 -0
- package/src/mcp-host.ts +4 -2
- package/src/messages.ts +15 -0
- package/src/metrics-endpoint.ts +60 -13
- package/src/otlp-export.ts +14 -0
- package/src/parse.ts +6 -1
- package/src/seo-meta.ts +105 -0
- package/src/serve.ts +15 -3
- package/src/shell-quote.ts +15 -0
- package/src/templates/action.ts +39 -7
- package/src/templates/backfill.ts +3 -1
- package/src/templates/index.ts +10 -1
- package/src/templates/job.ts +6 -2
- package/src/templates/query.ts +6 -1
- package/src/templates/route.ts +18 -9
- package/src/templates/scaffold-api.ts +100 -0
- package/src/templates/scaffold-app.ts +8 -48
- package/src/templates/scaffold-container.ts +44 -9
- package/src/templates/scaffold-helm-templates.ts +327 -0
- package/src/templates/scaffold-helm.ts +144 -0
- package/src/templates/scaffold-repo.ts +25 -8
- package/src/test-shards.ts +1 -10
- package/src/test-workers.ts +4 -1
- package/src/ts-scan.ts +25 -176
- package/src/tsconfig-references.ts +27 -2
- package/src/verify-step.ts +5 -0
package/src/ts-scan.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
// Reading
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Reading TypeScript source without a parser: the masking every scan here shares, and the `X_*`
|
|
2
|
+
// codes a package declares. Deliberately not `tsc` — a regex over a masked file is the whole job.
|
|
3
|
+
// Masking is the load-bearing part: the contract's own 3-line rendering appears verbatim in doc
|
|
4
|
+
// blocks and template literals, and a scanner that reads it as code invents work. What a `fix:`
|
|
5
|
+
// can evaluate to is `fix-scan.ts`, which reads these primitives and is the only file that grew.
|
|
5
6
|
|
|
6
7
|
export interface SourceSite {
|
|
7
8
|
/** Repo-relative file the site was read from. */
|
|
@@ -18,9 +19,10 @@ export interface CodeSite extends SourceSite {
|
|
|
18
19
|
readonly code: string;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
const
|
|
22
|
+
// `ReadonlySet`, so a consumer cannot mutate what every scan in this package reads.
|
|
23
|
+
export const QUOTES: ReadonlySet<string> = new Set(["'", '"', '`']);
|
|
24
|
+
export const OPENERS: ReadonlySet<string> = new Set(['(', '[', '{']);
|
|
25
|
+
export const CLOSERS: ReadonlySet<string> = new Set([')', ']', '}']);
|
|
24
26
|
const WORD = /[\w$]/;
|
|
25
27
|
|
|
26
28
|
/** After one of these words a `/` opens a regex; after any other identifier it divides. */
|
|
@@ -28,14 +30,23 @@ const REGEX_AFTER_WORDS = new Set(
|
|
|
28
30
|
'await case delete do else in instanceof new of return throw typeof void yield'.split(' '),
|
|
29
31
|
);
|
|
30
32
|
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Index just past the closing quote of the literal opening at `from`, or `from + 1` when a `'`/`"`
|
|
35
|
+
* does not close on its own line — which makes it text, not a literal. Only a template literal may
|
|
36
|
+
* span a newline, so the apostrophe in `<p>Don't panic</p>` is JSX text; read as an opener it ran
|
|
37
|
+
* forward to the next `'` in the FILE (the next `fix:` line) and blanked every declaration between,
|
|
38
|
+
* silently emptying the `errors` gate for the whole file. Same rule `endOfRegex` applies to a `/`.
|
|
39
|
+
* An escaped newline is still a continuation: the escape is consumed before the line test.
|
|
40
|
+
*/
|
|
41
|
+
export function endOfLiteral(text: string, from: number): number {
|
|
33
42
|
const quote = text[from] as string;
|
|
43
|
+
const spansLines = quote === '`';
|
|
34
44
|
for (let i = from + 1; i < text.length; i += 1) {
|
|
35
45
|
if (text[i] === '\\') i += 1;
|
|
36
46
|
else if (text[i] === quote) return i + 1;
|
|
47
|
+
else if (!spansLines && text[i] === '\n') return from + 1;
|
|
37
48
|
}
|
|
38
|
-
return text.length;
|
|
49
|
+
return spansLines ? text.length : from + 1;
|
|
39
50
|
}
|
|
40
51
|
|
|
41
52
|
/**
|
|
@@ -123,7 +134,7 @@ export const maskLiterals = (text: string): string => blankRegions(text, true);
|
|
|
123
134
|
* asking for a line per literal pays once per literal — measured at ~15s over the framework's own
|
|
124
135
|
* package tree, against ~1s for the same walk with this. One offset table, then a binary search.
|
|
125
136
|
*/
|
|
126
|
-
function lineIndex(text: string): (index: number) => number {
|
|
137
|
+
export function lineIndex(text: string): (index: number) => number {
|
|
127
138
|
const newlines: number[] = [];
|
|
128
139
|
for (let i = 0; i < text.length; i += 1) if (text[i] === '\n') newlines.push(i);
|
|
129
140
|
return (index) => {
|
|
@@ -144,7 +155,7 @@ function lineIndex(text: string): (index: number) => number {
|
|
|
144
155
|
* instead of silently skipped; the depth rule is what keeps `command.join(' ')`'s separator and
|
|
145
156
|
* `table['key']`'s key out — an argument is not a fix.
|
|
146
157
|
*/
|
|
147
|
-
function valueLiterals(
|
|
158
|
+
export function valueLiterals(
|
|
148
159
|
masked: string,
|
|
149
160
|
source: string,
|
|
150
161
|
from: number,
|
|
@@ -156,6 +167,8 @@ function valueLiterals(
|
|
|
156
167
|
const ch = masked[i] as string;
|
|
157
168
|
if (QUOTES.has(ch)) {
|
|
158
169
|
const end = endOfLiteral(masked, i);
|
|
170
|
+
// A quote that never closes is one character of code, not an empty literal to report.
|
|
171
|
+
if (end === i + 1) continue;
|
|
159
172
|
if (depth === 0) found.push({ value: source.slice(i + 1, end - 1), index: i });
|
|
160
173
|
i = end - 1;
|
|
161
174
|
} else if (OPENERS.has(ch)) depth += 1;
|
|
@@ -171,170 +184,6 @@ function valueLiterals(
|
|
|
171
184
|
}));
|
|
172
185
|
}
|
|
173
186
|
|
|
174
|
-
/** The lookbehind rejects member access: `cond ? e.fix : ''` is a ternary, not a declaration. */
|
|
175
|
-
const FIX_KEY = /(?<![.\w$])fix\s*:\s*/g;
|
|
176
|
-
|
|
177
|
-
/** Text between the bracket at `open` and its match, or `undefined` when it never closes. */
|
|
178
|
-
function bracketSpan(masked: string, open: number): string | undefined {
|
|
179
|
-
let depth = 0;
|
|
180
|
-
for (let i = open; i < masked.length; i += 1) {
|
|
181
|
-
const ch = masked[i] as string;
|
|
182
|
-
// Only `()[]{}`. An angle bracket is a generic in a parameter list and the tail of `=>` in the
|
|
183
|
-
// very same list, so counting it makes `(fn: () => void)` end the span in the wrong place.
|
|
184
|
-
if (OPENERS.has(ch)) depth += 1;
|
|
185
|
-
else if (CLOSERS.has(ch)) {
|
|
186
|
-
depth -= 1;
|
|
187
|
-
if (depth === 0) return masked.slice(open + 1, i);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
return undefined;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** Split at depth-0 commas. Safe on masked text, where a comma inside a literal is already gone. */
|
|
194
|
-
function topLevelParts(text: string): readonly string[] {
|
|
195
|
-
const parts: string[] = [];
|
|
196
|
-
let depth = 0;
|
|
197
|
-
let start = 0;
|
|
198
|
-
for (let i = 0; i < text.length; i += 1) {
|
|
199
|
-
const ch = text[i] as string;
|
|
200
|
-
if (OPENERS.has(ch)) depth += 1;
|
|
201
|
-
else if (CLOSERS.has(ch)) depth -= 1;
|
|
202
|
-
else if (ch === ',' && depth === 0) {
|
|
203
|
-
parts.push(text.slice(start, i));
|
|
204
|
-
start = i + 1;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
parts.push(text.slice(start));
|
|
208
|
-
return parts;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/** A local function that builds an error and takes its fix positionally, and where in its list. */
|
|
212
|
-
interface FixHelper {
|
|
213
|
-
readonly name: string;
|
|
214
|
-
readonly index: number;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
const HELPER_DECL =
|
|
218
|
-
/(?<![.\w$])(?:function\s+([A-Za-z_$][\w$]*)\s*\(|(?:const|let)\s+([A-Za-z_$][\w$]*)\s*(?::[^=;]*)?=\s*(?:async\s+)?\()/g;
|
|
219
|
-
|
|
220
|
-
const FIX_PARAM = /^\s*fix\s*:\s*string\s*$/;
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* What separates a helper that BUILDS a fix from one that CONSUMES one. `citedCommandProblem(fix:
|
|
224
|
-
* string, …)` in `fix-command.ts` takes a fix in order to judge it, and reading its call sites as
|
|
225
|
-
* declarations would report findings about strings that are already findings. A builder names a
|
|
226
|
-
* `code` or constructs an `…Error`; a consumer does neither.
|
|
227
|
-
*/
|
|
228
|
-
const BUILDS_ERROR = /(?<![.\w$])code\s*[:=]|new\s+[A-Za-z_$][\w$]*Error\s*\(/;
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* The body of the declaration whose parameter list ends at `after`, and never a `{` belonging to
|
|
232
|
-
* something below it. An unbounded `indexOf('{')` reads the next object literal in the FILE when
|
|
233
|
-
* the body is a concise expression, so `const label = (fix: string) => fix.trim();` followed
|
|
234
|
-
* anywhere by a `{ code: … }` was read as an error builder and every `label(…)` call handed the
|
|
235
|
-
* gate a string to judge as a fix — a false gate failure over innocent source.
|
|
236
|
-
*
|
|
237
|
-
* The scan therefore ends at the `;` that ends the declaration, or at a bracket closing a scope
|
|
238
|
-
* this declaration is inside. Both directions of that bound answer `''`, which classifies the
|
|
239
|
-
* helper as a non-builder: a missed fix line costs one unchecked citation, a wrongly claimed one
|
|
240
|
-
* costs a build. A `{` inside a return-type annotation (`(): { ok: boolean } => …`) is read as the
|
|
241
|
-
* body and answers `''` for the same reason.
|
|
242
|
-
*/
|
|
243
|
-
function bodyOf(masked: string, after: number): string {
|
|
244
|
-
for (let i = after; i < masked.length; i += 1) {
|
|
245
|
-
const ch = masked[i] as string;
|
|
246
|
-
if (ch === '{') return bracketSpan(masked, i) ?? '';
|
|
247
|
-
if (ch === ';' || CLOSERS.has(ch)) break;
|
|
248
|
-
}
|
|
249
|
-
return '';
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function fixHelpers(masked: string): readonly FixHelper[] {
|
|
253
|
-
const helpers: FixHelper[] = [];
|
|
254
|
-
for (const declaration of masked.matchAll(HELPER_DECL)) {
|
|
255
|
-
const name = declaration[1] ?? declaration[2];
|
|
256
|
-
const open = declaration.index + declaration[0].length - 1;
|
|
257
|
-
if (name === undefined || masked[open] !== '(') continue;
|
|
258
|
-
const params = bracketSpan(masked, open);
|
|
259
|
-
// A rest parameter makes the position of everything after it unknowable, and a destructured
|
|
260
|
-
// one has no position at all — its `fix:` key at the CALL site is already read by `FIX_KEY`.
|
|
261
|
-
if (params === undefined || params.includes('...')) continue;
|
|
262
|
-
const parts = topLevelParts(params);
|
|
263
|
-
if (parts.some((part) => /^\s*[[{]/.test(part))) continue;
|
|
264
|
-
const index = parts.findIndex((part) => FIX_PARAM.test(part));
|
|
265
|
-
if (index === -1) continue;
|
|
266
|
-
if (!BUILDS_ERROR.test(bodyOf(masked, open + params.length + 2))) continue;
|
|
267
|
-
helpers.push({ name, index });
|
|
268
|
-
}
|
|
269
|
-
return helpers;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* The argument in that position at every call to that helper IN THIS FILE.
|
|
274
|
-
*
|
|
275
|
-
* Same file, deliberately: resolving `dbNotImplemented` imported from `@ultimat3/db` would mean a
|
|
276
|
-
* cross-file symbol table, and a scanner that guessed at which import a name came from would read
|
|
277
|
-
* an unrelated function's argument as a fix. The gap that leaves is named in `CLAUDE.md`.
|
|
278
|
-
*/
|
|
279
|
-
function helperFixSites(
|
|
280
|
-
masked: string,
|
|
281
|
-
source: string,
|
|
282
|
-
at: string,
|
|
283
|
-
helper: FixHelper,
|
|
284
|
-
lineAt: (index: number) => number,
|
|
285
|
-
): readonly FixSite[] {
|
|
286
|
-
const sites: FixSite[] = [];
|
|
287
|
-
// The lookbehind is `FIX_KEY`'s: `reporter.rejected(…)` is some other object's method.
|
|
288
|
-
const call = new RegExp(`(?<![.\\w$])${helper.name}\\s*\\(`, 'g');
|
|
289
|
-
for (const match of masked.matchAll(call)) {
|
|
290
|
-
const open = match.index + match[0].length - 1;
|
|
291
|
-
const args = bracketSpan(masked, open);
|
|
292
|
-
if (args === undefined) continue;
|
|
293
|
-
const parts = topLevelParts(args);
|
|
294
|
-
const argument = parts[helper.index];
|
|
295
|
-
if (argument === undefined) continue;
|
|
296
|
-
// Stricter than the `fix:` path, and deliberately: the whole argument must BE one literal.
|
|
297
|
-
// `valueLiterals` alone reads `prefix + 'x doctor'` as one literal, because the identifier
|
|
298
|
-
// half contributes none — and publishing half a fix as the whole one is the failure
|
|
299
|
-
// `soleLiteral` already names. A key at least declares that what follows is the value.
|
|
300
|
-
const quote = argument.trim()[0];
|
|
301
|
-
if (quote === undefined || !QUOTES.has(quote)) continue;
|
|
302
|
-
const literal = argument.indexOf(quote);
|
|
303
|
-
if (argument.slice(endOfLiteral(argument, literal)).trim() !== '') continue;
|
|
304
|
-
const from = parts.slice(0, helper.index).reduce((n, part) => n + part.length + 1, open + 1);
|
|
305
|
-
const literals = valueLiterals(masked, source, from, lineAt);
|
|
306
|
-
if (literals.length === 1) sites.push({ ...(literals[0] as FixSite), at });
|
|
307
|
-
}
|
|
308
|
-
return sites;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/**
|
|
312
|
-
* Every string a `fix:` can evaluate to. Searched over the masked source, so a `fix:` written
|
|
313
|
-
* inside a doc comment or interpolated into a message is not mistaken for a declaration. A `fix`
|
|
314
|
-
* computed at runtime — a bare identifier, a parameter, a table lookup with no literal fallback —
|
|
315
|
-
* has nothing to read and is beyond a static scan; the gate says so rather than guessing.
|
|
316
|
-
*
|
|
317
|
-
* Two shapes, because a fix does not always arrive under a key. `@ultimat3/mcp`'s `readonly-sql.ts`
|
|
318
|
-
* hands every one of its fixes positionally to a local `rejected(cause, fix)` helper, so the key
|
|
319
|
-
* rule alone returned `[]` for the whole file — 20 non-test files in that package and the scanner
|
|
320
|
-
* saw fixes in three — and two stale `x db branch <name>` lines shipped through the hole.
|
|
321
|
-
*/
|
|
322
|
-
export function scanFixes(source: string, at: string): readonly FixSite[] {
|
|
323
|
-
const masked = maskLiterals(source);
|
|
324
|
-
const lineAt = lineIndex(masked);
|
|
325
|
-
const sites: FixSite[] = [];
|
|
326
|
-
for (const key of masked.matchAll(FIX_KEY)) {
|
|
327
|
-
const start = key.index + key[0].length;
|
|
328
|
-
for (const literal of valueLiterals(masked, source, start, lineAt)) {
|
|
329
|
-
sites.push({ ...literal, at });
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
for (const helper of fixHelpers(masked)) {
|
|
333
|
-
sites.push(...helperFixSites(masked, source, at, helper, lineAt));
|
|
334
|
-
}
|
|
335
|
-
return sites;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
187
|
const CODE_TABLE = /\bexport const [A-Z][A-Z0-9_]*_ERROR_(?:CODES|TITLES)\b/;
|
|
339
188
|
|
|
340
189
|
/**
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { docsFor } from './error-codes';
|
|
11
11
|
import type { Finding } from './output';
|
|
12
|
+
import { maskLiterals, stripComments } from './ts-scan';
|
|
12
13
|
|
|
13
14
|
const ROOT_TSCONFIG = 'tsconfig.json';
|
|
14
15
|
|
|
@@ -22,15 +23,39 @@ const ROOT_TSCONFIG = 'tsconfig.json';
|
|
|
22
23
|
export const normalizeReferencePath = (path: string): string =>
|
|
23
24
|
path.replace(/^\.\//, '').replace(/\/+$/, '');
|
|
24
25
|
|
|
26
|
+
/**
|
|
27
|
+
* A tsconfig is JSONC and `JSON.parse` is not, so the comments and trailing commas `tsc` accepts —
|
|
28
|
+
* and `tsc --init` writes — are removed before the parse. `Bun.file().json()` rejects both, and
|
|
29
|
+
* the rejection was mapped to "this root does not use project references": the check went dark on
|
|
30
|
+
* exactly the roots most likely to have been written by hand, while `typecheck` stayed green over
|
|
31
|
+
* every package it then skipped. `stripComments` is `ts-scan.ts`'s, so a `//` inside a string is a
|
|
32
|
+
* URL and not a comment; the trailing-comma pass reads MASKED text for the same reason.
|
|
33
|
+
*/
|
|
34
|
+
function parseJsonc(text: string): unknown {
|
|
35
|
+
const uncommented = stripComments(text);
|
|
36
|
+
const masked = maskLiterals(uncommented);
|
|
37
|
+
const chars = [...uncommented];
|
|
38
|
+
for (let i = 0; i < masked.length; i += 1) {
|
|
39
|
+
if (masked[i] !== ',') continue;
|
|
40
|
+
let next = i + 1;
|
|
41
|
+
while (next < masked.length && /\s/.test(masked[next] as string)) next += 1;
|
|
42
|
+
if (masked[next] === '}' || masked[next] === ']') chars[i] = ' ';
|
|
43
|
+
}
|
|
44
|
+
return JSON.parse(chars.join('')) as unknown;
|
|
45
|
+
}
|
|
46
|
+
|
|
25
47
|
/**
|
|
26
48
|
* `undefined` means "this root does not use project references" — a different repo shape, not an
|
|
27
49
|
* empty graph. A scaffolded app is that shape (`extends` + `include`, no references at all), and
|
|
28
50
|
* telling its author to add an entry to a list that does not exist is a fix that makes the build
|
|
29
|
-
* worse. A tsconfig that will not parse is `typecheck`'s to report, with tsc's own
|
|
51
|
+
* worse. A tsconfig that will not parse EVEN AS JSONC is `typecheck`'s to report, with tsc's own
|
|
52
|
+
* message — this check has no code that would mean "the file is broken" and must not borrow one
|
|
53
|
+
* that means something else.
|
|
30
54
|
*/
|
|
31
55
|
async function referencedPaths(root: string): Promise<ReadonlySet<string> | undefined> {
|
|
32
56
|
const payload: unknown = await Bun.file(join(root, ROOT_TSCONFIG))
|
|
33
|
-
.
|
|
57
|
+
.text()
|
|
58
|
+
.then(parseJsonc)
|
|
34
59
|
.catch(() => undefined);
|
|
35
60
|
const references =
|
|
36
61
|
typeof payload === 'object' && payload !== null
|
package/src/verify-step.ts
CHANGED
|
@@ -28,6 +28,11 @@ export const VERIFY_STEP_NAMES = [
|
|
|
28
28
|
'drift',
|
|
29
29
|
'contract-diff',
|
|
30
30
|
'budgets',
|
|
31
|
+
// Eighteenth, and a deliberate widening of a closed list rather than a `HostCheck`: an SEO gate
|
|
32
|
+
// is a MECHANISM every app with a `site/` surface wants (axiom 8), not a rule one host repo
|
|
33
|
+
// enforces — and `verifyCommand.run` passes no host checks at all, so the app path could not
|
|
34
|
+
// have carried it. It runs beside `budgets` because both read the app the same load produced.
|
|
35
|
+
'seo',
|
|
31
36
|
'manifest',
|
|
32
37
|
'roadmap',
|
|
33
38
|
] as const;
|