tina4-nodejs 3.13.89 → 3.13.91
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 +3 -3
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +46 -18
- package/packages/cli/src/commands/generate.ts +43 -17
- package/packages/core/dist/index.js +19 -1
- package/packages/core/public/js/tina4-dev-admin.min.js +179 -179
- package/packages/core/src/metrics.ts +53 -1
- package/packages/orm/dist/index.js +19 -1
package/CLAUDE.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
# CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.
|
|
1
|
+
# CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.91)
|
|
2
2
|
|
|
3
3
|
> This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
|
|
4
4
|
|
|
5
5
|
## What This Project Is
|
|
6
6
|
|
|
7
|
-
Tina4 for Node.js/TypeScript v3.13.
|
|
7
|
+
Tina4 for Node.js/TypeScript v3.13.91 - The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
|
|
8
8
|
|
|
9
9
|
The philosophy: zero ceremony, batteries included, file system as source of truth.
|
|
10
10
|
|
|
@@ -1244,7 +1244,7 @@ When adding new features, add a corresponding `test/<feature>.test.ts` file.
|
|
|
1244
1244
|
## v3 Features Summary
|
|
1245
1245
|
|
|
1246
1246
|
- **98 built-in features**, zero third-party dependencies
|
|
1247
|
-
- **5,
|
|
1247
|
+
- **5,932 tests** passing across 190 files (build + typecheck green)
|
|
1248
1248
|
- **Race-safe `getNextId()`** with atomic sequence table (`tina4_sequences`) for SQLite/MySQL/MSSQL; PostgreSQL auto-creates sequences
|
|
1249
1249
|
- **Frond template engine optimizations**: pre-compiled regexes, lazy loop context (copy-on-write), filter chain caching, path split caching, inline common filters (11-15% speedup)
|
|
1250
1250
|
- **Production server auto-detect**: `npx tina4nodejs serve --production` auto-uses cluster mode
|
package/package.json
CHANGED
package/packages/cli/dist/bin.js
CHANGED
|
@@ -17938,7 +17938,7 @@ function extractFunctions(source, filePath, root = ".") {
|
|
|
17938
17938
|
if (funcName !== null && !NON_FUNCTION_WORDS.has(funcName)) {
|
|
17939
17939
|
const displayName = funcName === "constructor" && currentClass ? `${currentClass}.constructor` : currentClass !== null && !isTopLevelDecl ? `${currentClass}.${funcName}` : funcName;
|
|
17940
17940
|
const funcBody = extractFunctionBody(lines, i);
|
|
17941
|
-
const funcLoc =
|
|
17941
|
+
const funcLoc = Math.max(1, countLines(funcBody).loc);
|
|
17942
17942
|
const complexity = cycloMaticComplexity(funcBody);
|
|
17943
17943
|
const args = argsStr.split(",").map((a) => a.trim().split(":")[0].split("=")[0].replace("?", "").trim()).filter((a) => a && a !== "this");
|
|
17944
17944
|
functions.push({
|
|
@@ -17954,6 +17954,24 @@ function extractFunctions(source, filePath, root = ".") {
|
|
|
17954
17954
|
currentClass = null;
|
|
17955
17955
|
}
|
|
17956
17956
|
}
|
|
17957
|
+
return chargeNestedComplexityToTheNestedFunction(functions);
|
|
17958
|
+
}
|
|
17959
|
+
function chargeNestedComplexityToTheNestedFunction(functions) {
|
|
17960
|
+
if (functions.length < 2) return functions;
|
|
17961
|
+
const lastLine = (f) => f.line + Math.max(1, f.loc) - 1;
|
|
17962
|
+
const contains = (outer, inner) => inner.line > outer.line && lastLine(inner) <= lastLine(outer);
|
|
17963
|
+
const raw = functions.map((f) => f.complexity);
|
|
17964
|
+
functions.forEach((outer, i) => {
|
|
17965
|
+
let subtract = 0;
|
|
17966
|
+
functions.forEach((inner, j) => {
|
|
17967
|
+
if (i === j || !contains(outer, inner)) return;
|
|
17968
|
+
const nestedDeeper = functions.some(
|
|
17969
|
+
(mid, k) => k !== i && k !== j && contains(outer, mid) && contains(mid, inner)
|
|
17970
|
+
);
|
|
17971
|
+
if (!nestedDeeper) subtract += raw[j] - 1;
|
|
17972
|
+
});
|
|
17973
|
+
outer.complexity = Math.max(1, raw[i] - subtract);
|
|
17974
|
+
});
|
|
17957
17975
|
return functions;
|
|
17958
17976
|
}
|
|
17959
17977
|
function extractFunctionBody(lines, startLine) {
|
|
@@ -38761,6 +38779,11 @@ function parseFields(fieldsStr) {
|
|
|
38761
38779
|
}
|
|
38762
38780
|
return result;
|
|
38763
38781
|
}
|
|
38782
|
+
var DEFAULT_FIELDS = [["name", "string"]];
|
|
38783
|
+
function fieldsOrDefault(fieldsStr) {
|
|
38784
|
+
const parsed = parseFields(fieldsStr);
|
|
38785
|
+
return parsed.length > 0 ? parsed : DEFAULT_FIELDS.map(([f, t]) => [f, t]);
|
|
38786
|
+
}
|
|
38764
38787
|
function parseCliArgs(args) {
|
|
38765
38788
|
const booleanFlags = /* @__PURE__ */ new Set([
|
|
38766
38789
|
"no-browser",
|
|
@@ -38876,7 +38899,7 @@ async function generate2(what, name, extraArgs = []) {
|
|
|
38876
38899
|
}
|
|
38877
38900
|
}
|
|
38878
38901
|
function generateModel(name, flags, emitTest = true) {
|
|
38879
|
-
const fields =
|
|
38902
|
+
const fields = fieldsOrDefault(flags.fields || "");
|
|
38880
38903
|
const table2 = toTableName(name);
|
|
38881
38904
|
const dir = resolve29("src/models");
|
|
38882
38905
|
ensureDir(dir);
|
|
@@ -38884,13 +38907,9 @@ function generateModel(name, flags, emitTest = true) {
|
|
|
38884
38907
|
const fieldLines = [
|
|
38885
38908
|
` id: { type: "integer" as const, primaryKey: true, autoIncrement: true },`
|
|
38886
38909
|
];
|
|
38887
|
-
|
|
38888
|
-
|
|
38889
|
-
|
|
38890
|
-
fieldLines.push(` ${fname}: { type: ${info.orm} as const },`);
|
|
38891
|
-
}
|
|
38892
|
-
} else {
|
|
38893
|
-
fieldLines.push(` name: { type: "string" as const },`);
|
|
38910
|
+
for (const [fname, ftype] of fields) {
|
|
38911
|
+
const info = FIELD_TYPE_MAP[ftype] || FIELD_TYPE_MAP.string;
|
|
38912
|
+
fieldLines.push(` ${fname}: { type: ${info.orm} as const },`);
|
|
38894
38913
|
}
|
|
38895
38914
|
fieldLines.push(` created_at: { type: "datetime" as const },`);
|
|
38896
38915
|
const content = `import { BaseModel } from "tina4-nodejs/orm";
|
|
@@ -38904,7 +38923,7 @@ ${fieldLines.join("\n")}
|
|
|
38904
38923
|
`;
|
|
38905
38924
|
writeFileSafe(path8, content);
|
|
38906
38925
|
if (!flags["no-migration"]) {
|
|
38907
|
-
generateMigration(`create_${table2}`, flags, fields
|
|
38926
|
+
generateMigration(`create_${table2}`, flags, fields, table2, false);
|
|
38908
38927
|
}
|
|
38909
38928
|
if (emitTest) emitModelTest(name, table2, fields);
|
|
38910
38929
|
}
|
|
@@ -38975,7 +38994,12 @@ ${extend(
|
|
|
38975
38994
|
"validate / business rules before persist",
|
|
38976
38995
|
`e.g. reject invalid input; ground: tina4_context("validate before create", "nodejs")`
|
|
38977
38996
|
)} const item = new ${model}(req.body as Record<string, unknown>);
|
|
38978
|
-
|
|
38997
|
+
// save() returns false on failure rather than throwing - check it, or a failed
|
|
38998
|
+
// write is reported to the client as a 201 carrying unsaved data.
|
|
38999
|
+
if ((await item.save()) === false) {
|
|
39000
|
+
res.json({ error: "Could not create ${singular}" }, 400);
|
|
39001
|
+
return;
|
|
39002
|
+
}
|
|
38979
39003
|
res.json({ data: item.toObject() }, 201);
|
|
38980
39004
|
}
|
|
38981
39005
|
`
|
|
@@ -39055,7 +39079,12 @@ ${extend(
|
|
|
39055
39079
|
"guard which fields / who may update",
|
|
39056
39080
|
`e.g. enforce ownership; ground: tina4_context("authorize update", "nodejs")`
|
|
39057
39081
|
)} Object.assign(item, req.body as Record<string, unknown>);
|
|
39058
|
-
|
|
39082
|
+
// save() returns false on failure rather than throwing - check it, or a failed
|
|
39083
|
+
// write is reported to the client as a 200 carrying unsaved data.
|
|
39084
|
+
if ((await item.save()) === false) {
|
|
39085
|
+
res.json({ error: "Could not update ${singular}" }, 400);
|
|
39086
|
+
return;
|
|
39087
|
+
}
|
|
39059
39088
|
res.json({ data: item.toObject() });
|
|
39060
39089
|
}
|
|
39061
39090
|
`
|
|
@@ -39362,7 +39391,7 @@ void test${titleName};
|
|
|
39362
39391
|
writeFileSafe(path8, content);
|
|
39363
39392
|
}
|
|
39364
39393
|
function generateForm(name, flags) {
|
|
39365
|
-
const fields =
|
|
39394
|
+
const fields = fieldsOrDefault(flags.fields || "");
|
|
39366
39395
|
const table2 = toTableName(name);
|
|
39367
39396
|
const routeName = toPlural(table2);
|
|
39368
39397
|
const inputTypes = {
|
|
@@ -39382,9 +39411,8 @@ function generateForm(name, flags) {
|
|
|
39382
39411
|
const dir = resolve29("src/templates/forms");
|
|
39383
39412
|
ensureDir(dir);
|
|
39384
39413
|
const path8 = join33(dir, `${table2}.twig`);
|
|
39385
|
-
const fieldEntries = fields.length > 0 ? fields : [["name", "string"]];
|
|
39386
39414
|
let fieldHtml = "";
|
|
39387
|
-
for (const [fname, ftype] of
|
|
39415
|
+
for (const [fname, ftype] of fields) {
|
|
39388
39416
|
const itype = inputTypes[ftype] || "text";
|
|
39389
39417
|
const label = fname.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
39390
39418
|
const step = ["float", "numeric", "decimal"].includes(ftype) ? ' step="0.01"' : "";
|
|
@@ -39428,10 +39456,10 @@ function generateForm(name, flags) {
|
|
|
39428
39456
|
writeFileSafe(path8, content);
|
|
39429
39457
|
}
|
|
39430
39458
|
function generateView(name, flags) {
|
|
39431
|
-
const fields =
|
|
39459
|
+
const fields = fieldsOrDefault(flags.fields || "");
|
|
39432
39460
|
const table2 = toTableName(name);
|
|
39433
39461
|
const routeName = toPlural(table2);
|
|
39434
|
-
const cols = fields.
|
|
39462
|
+
const cols = fields.map(([f]) => f);
|
|
39435
39463
|
const dir = resolve29("src/templates/pages");
|
|
39436
39464
|
ensureDir(dir);
|
|
39437
39465
|
const listPath = join33(dir, `${routeName}.twig`);
|
|
@@ -39946,7 +39974,7 @@ function sampleLiteral(fieldType) {
|
|
|
39946
39974
|
}
|
|
39947
39975
|
}
|
|
39948
39976
|
function emitModelTest(model, table2, fields) {
|
|
39949
|
-
const flds = fields.length > 0 ? fields : [[
|
|
39977
|
+
const flds = fields.length > 0 ? fields : DEFAULT_FIELDS.map(([f, t]) => [f, t]);
|
|
39950
39978
|
const payload = flds.map(([f, t]) => `${f}: ${sampleLiteral(t)}`).join(", ");
|
|
39951
39979
|
const stringField = flds.find(([, t]) => ["string", "str", "text"].includes((t || "string").toLowerCase()))?.[0];
|
|
39952
39980
|
const valueAssert = stringField ? `
|
|
@@ -115,6 +115,21 @@ export function parseFields(fieldsStr: string): Array<[string, string]> {
|
|
|
115
115
|
return result;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
// Called without --fields, the generators fall back to a single `name` string
|
|
119
|
+
// column. That default MUST be materialised here, in one place, and then flow
|
|
120
|
+
// into the model, the migration, the form, the view and the test alike. It used
|
|
121
|
+
// to live only inside the model template, so `generate model X` / `generate
|
|
122
|
+
// crud X` wrote a model declaring `name` while the migration - built from the
|
|
123
|
+
// parsed field list, which was empty - created only id + created_at. The first
|
|
124
|
+
// write then failed with "table x has no column named name".
|
|
125
|
+
export const DEFAULT_FIELDS: ReadonlyArray<[string, string]> = [["name", "string"]];
|
|
126
|
+
|
|
127
|
+
/** Parsed --fields, or the default single `name` column when none given. */
|
|
128
|
+
export function fieldsOrDefault(fieldsStr: string): Array<[string, string]> {
|
|
129
|
+
const parsed = parseFields(fieldsStr);
|
|
130
|
+
return parsed.length > 0 ? parsed : DEFAULT_FIELDS.map(([f, t]) => [f, t] as [string, string]);
|
|
131
|
+
}
|
|
132
|
+
|
|
118
133
|
export function parseCliArgs(args: string[]): { flags: Record<string, string | boolean>; positional: string[] } {
|
|
119
134
|
// Boolean-only flags that never take a value argument.
|
|
120
135
|
const booleanFlags = new Set([
|
|
@@ -290,7 +305,7 @@ export async function generate(what: string, name: string, extraArgs: string[] =
|
|
|
290
305
|
// ── Model ───────────────────────────────────────────────────────────
|
|
291
306
|
|
|
292
307
|
function generateModel(name: string, flags: Record<string, string | boolean>, emitTest = true): void {
|
|
293
|
-
const fields =
|
|
308
|
+
const fields = fieldsOrDefault((flags.fields as string) || "");
|
|
294
309
|
const table = toTableName(name);
|
|
295
310
|
const dir = resolve("src/models");
|
|
296
311
|
ensureDir(dir);
|
|
@@ -300,13 +315,9 @@ function generateModel(name: string, flags: Record<string, string | boolean>, em
|
|
|
300
315
|
const fieldLines: string[] = [
|
|
301
316
|
` id: { type: "integer" as const, primaryKey: true, autoIncrement: true },`,
|
|
302
317
|
];
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
fieldLines.push(` ${fname}: { type: ${info.orm} as const },`);
|
|
307
|
-
}
|
|
308
|
-
} else {
|
|
309
|
-
fieldLines.push(` name: { type: "string" as const },`);
|
|
318
|
+
for (const [fname, ftype] of fields) {
|
|
319
|
+
const info = FIELD_TYPE_MAP[ftype] || FIELD_TYPE_MAP.string;
|
|
320
|
+
fieldLines.push(` ${fname}: { type: ${info.orm} as const },`);
|
|
310
321
|
}
|
|
311
322
|
fieldLines.push(` created_at: { type: "datetime" as const },`);
|
|
312
323
|
|
|
@@ -328,7 +339,11 @@ ${fieldLines.join("\n")}
|
|
|
328
339
|
// (below) proves the schema through the real ORM, so the migration sub-call
|
|
329
340
|
// does NOT also co-emit a migration test (emitTest=false).
|
|
330
341
|
if (!flags["no-migration"]) {
|
|
331
|
-
|
|
342
|
+
// Always hand over the RESOLVED field list. Passing `undefined` when the
|
|
343
|
+
// parsed list was empty made the migration fall back to its own
|
|
344
|
+
// parseFields() - also empty - so the table got only id + created_at while
|
|
345
|
+
// the model above declared `name`, and the first write 500'd.
|
|
346
|
+
generateMigration(`create_${table}`, flags, fields, table, false);
|
|
332
347
|
}
|
|
333
348
|
|
|
334
349
|
// Co-emit a real SQLite roundtrip test next to the model. Composite
|
|
@@ -417,7 +432,12 @@ ${modelImportBase}${secureOptOut(isPublic)}export const meta = { summary: "Creat
|
|
|
417
432
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
418
433
|
${extend("validate / business rules before persist",
|
|
419
434
|
`e.g. reject invalid input; ground: tina4_context("validate before create", "nodejs")`)} const item = new ${model}(req.body as Record<string, unknown>);
|
|
420
|
-
|
|
435
|
+
// save() returns false on failure rather than throwing - check it, or a failed
|
|
436
|
+
// write is reported to the client as a 201 carrying unsaved data.
|
|
437
|
+
if ((await item.save()) === false) {
|
|
438
|
+
res.json({ error: "Could not create ${singular}" }, 400);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
421
441
|
res.json({ data: item.toObject() }, 201);
|
|
422
442
|
}
|
|
423
443
|
`,
|
|
@@ -499,7 +519,12 @@ export default async function (req: Tina4Request, res: Tina4Response) {
|
|
|
499
519
|
}
|
|
500
520
|
${extend("guard which fields / who may update",
|
|
501
521
|
`e.g. enforce ownership; ground: tina4_context("authorize update", "nodejs")`)} Object.assign(item, req.body as Record<string, unknown>);
|
|
502
|
-
|
|
522
|
+
// save() returns false on failure rather than throwing - check it, or a failed
|
|
523
|
+
// write is reported to the client as a 200 carrying unsaved data.
|
|
524
|
+
if ((await item.save()) === false) {
|
|
525
|
+
res.json({ error: "Could not update ${singular}" }, 400);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
503
528
|
res.json({ data: item.toObject() });
|
|
504
529
|
}
|
|
505
530
|
`,
|
|
@@ -873,7 +898,7 @@ void test${titleName};
|
|
|
873
898
|
// ── Form ────────────────────────────────────────────────────────────
|
|
874
899
|
|
|
875
900
|
function generateForm(name: string, flags: Record<string, string | boolean>): void {
|
|
876
|
-
const fields =
|
|
901
|
+
const fields = fieldsOrDefault((flags.fields as string) || "");
|
|
877
902
|
const table = toTableName(name);
|
|
878
903
|
const routeName = toPlural(table);
|
|
879
904
|
|
|
@@ -890,9 +915,8 @@ function generateForm(name: string, flags: Record<string, string | boolean>): vo
|
|
|
890
915
|
const path = join(dir, `${table}.twig`);
|
|
891
916
|
|
|
892
917
|
// Build form fields
|
|
893
|
-
const fieldEntries = fields.length > 0 ? fields : [["name", "string"] as [string, string]];
|
|
894
918
|
let fieldHtml = "";
|
|
895
|
-
for (const [fname, ftype] of
|
|
919
|
+
for (const [fname, ftype] of fields) {
|
|
896
920
|
const itype = inputTypes[ftype] || "text";
|
|
897
921
|
const label = fname.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
898
922
|
const step = ["float", "numeric", "decimal"].includes(ftype) ? ' step="0.01"' : "";
|
|
@@ -946,11 +970,11 @@ function generateForm(name: string, flags: Record<string, string | boolean>): vo
|
|
|
946
970
|
// ── View ────────────────────────────────────────────────────────────
|
|
947
971
|
|
|
948
972
|
function generateView(name: string, flags: Record<string, string | boolean>): void {
|
|
949
|
-
const fields =
|
|
973
|
+
const fields = fieldsOrDefault((flags.fields as string) || "");
|
|
950
974
|
const table = toTableName(name);
|
|
951
975
|
const routeName = toPlural(table);
|
|
952
976
|
|
|
953
|
-
const cols = fields.
|
|
977
|
+
const cols = fields.map(([f]) => f);
|
|
954
978
|
|
|
955
979
|
const dir = resolve("src/templates/pages");
|
|
956
980
|
ensureDir(dir);
|
|
@@ -1591,7 +1615,9 @@ function sampleLiteral(fieldType: string): string {
|
|
|
1591
1615
|
|
|
1592
1616
|
/** model → real SQLite roundtrip (create / read back / missing → null). */
|
|
1593
1617
|
function emitModelTest(model: string, table: string, fields: Array<[string, string]>): void {
|
|
1594
|
-
|
|
1618
|
+
// Reuse the single DEFAULT_FIELDS constant rather than re-stating the literal,
|
|
1619
|
+
// so the co-emitted test can never describe a shape the model does not have.
|
|
1620
|
+
const flds = fields.length > 0 ? fields : DEFAULT_FIELDS.map(([f, t]) => [f, t] as [string, string]);
|
|
1595
1621
|
const payload = flds.map(([f, t]) => `${f}: ${sampleLiteral(t)}`).join(", ");
|
|
1596
1622
|
const stringField = flds.find(([, t]) => ["string", "str", "text"].includes((t || "string").toLowerCase()))?.[0];
|
|
1597
1623
|
const valueAssert = stringField
|
|
@@ -17937,7 +17937,7 @@ function extractFunctions(source, filePath, root = ".") {
|
|
|
17937
17937
|
if (funcName !== null && !NON_FUNCTION_WORDS.has(funcName)) {
|
|
17938
17938
|
const displayName = funcName === "constructor" && currentClass ? `${currentClass}.constructor` : currentClass !== null && !isTopLevelDecl ? `${currentClass}.${funcName}` : funcName;
|
|
17939
17939
|
const funcBody = extractFunctionBody(lines, i);
|
|
17940
|
-
const funcLoc =
|
|
17940
|
+
const funcLoc = Math.max(1, countLines(funcBody).loc);
|
|
17941
17941
|
const complexity = cycloMaticComplexity(funcBody);
|
|
17942
17942
|
const args = argsStr.split(",").map((a) => a.trim().split(":")[0].split("=")[0].replace("?", "").trim()).filter((a) => a && a !== "this");
|
|
17943
17943
|
functions.push({
|
|
@@ -17953,6 +17953,24 @@ function extractFunctions(source, filePath, root = ".") {
|
|
|
17953
17953
|
currentClass = null;
|
|
17954
17954
|
}
|
|
17955
17955
|
}
|
|
17956
|
+
return chargeNestedComplexityToTheNestedFunction(functions);
|
|
17957
|
+
}
|
|
17958
|
+
function chargeNestedComplexityToTheNestedFunction(functions) {
|
|
17959
|
+
if (functions.length < 2) return functions;
|
|
17960
|
+
const lastLine = (f) => f.line + Math.max(1, f.loc) - 1;
|
|
17961
|
+
const contains = (outer, inner) => inner.line > outer.line && lastLine(inner) <= lastLine(outer);
|
|
17962
|
+
const raw = functions.map((f) => f.complexity);
|
|
17963
|
+
functions.forEach((outer, i) => {
|
|
17964
|
+
let subtract = 0;
|
|
17965
|
+
functions.forEach((inner, j) => {
|
|
17966
|
+
if (i === j || !contains(outer, inner)) return;
|
|
17967
|
+
const nestedDeeper = functions.some(
|
|
17968
|
+
(mid, k) => k !== i && k !== j && contains(outer, mid) && contains(mid, inner)
|
|
17969
|
+
);
|
|
17970
|
+
if (!nestedDeeper) subtract += raw[j] - 1;
|
|
17971
|
+
});
|
|
17972
|
+
outer.complexity = Math.max(1, raw[i] - subtract);
|
|
17973
|
+
});
|
|
17956
17974
|
return functions;
|
|
17957
17975
|
}
|
|
17958
17976
|
function extractFunctionBody(lines, startLine) {
|