tina4-nodejs 3.13.124 → 3.13.130
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 +2 -2
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +455 -85
- package/packages/cli/src/bin.ts +6 -0
- package/packages/cli/src/commands/generate.ts +93 -19
- package/packages/cli/src/commands/lint.ts +360 -0
- package/packages/core/dist/index.js +172 -23
- package/packages/core/src/fakeData.ts +37 -2
- package/packages/orm/dist/index.js +172 -23
- package/packages/orm/src/adapters/firebird.ts +7 -0
- package/packages/orm/src/adapters/mssql.ts +10 -0
- package/packages/orm/src/adapters/mysql.ts +6 -0
- package/packages/orm/src/fakeData.ts +27 -2
- package/packages/orm/src/seeder.ts +8 -3
- package/packages/orm/src/sqlTranslator.ts +45 -0
- package/types/cli/src/commands/generate.d.ts +29 -0
- package/types/cli/src/commands/lint.d.ts +5 -0
- package/types/core/src/fakeData.d.ts +12 -0
- package/types/orm/src/fakeData.d.ts +12 -1
- package/types/orm/src/sqlTranslator.d.ts +19 -0
package/packages/cli/src/bin.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { migrateStatus } from "./commands/migrateStatus.js";
|
|
|
6
6
|
import { migrateRollback } from "./commands/migrateRollback.js";
|
|
7
7
|
import { listRoutes } from "./commands/routes.js";
|
|
8
8
|
import { runTests } from "./commands/test.js";
|
|
9
|
+
import { runLint } from "./commands/lint.js";
|
|
9
10
|
import { generate, GENERATORS, RESOLUTION_ENVELOPE_VERSION } from "./commands/generate.js";
|
|
10
11
|
import { runSeeds } from "./commands/seed.js";
|
|
11
12
|
import { queueCommand, QUEUE_SUBCOMMAND_NAMES } from "./commands/queue.js";
|
|
@@ -370,6 +371,11 @@ export const COMMANDS: Record<string, CommandSpec> = {
|
|
|
370
371
|
usage: "[file]",
|
|
371
372
|
summary: "Run project tests",
|
|
372
373
|
},
|
|
374
|
+
lint: {
|
|
375
|
+
handler: (a) => { runLint(a); },
|
|
376
|
+
usage: "[--fix] [--no-install]",
|
|
377
|
+
summary: "Lint the project (eslint, installed dev-only on demand; else tsc/node --check baseline)",
|
|
378
|
+
},
|
|
373
379
|
queue: {
|
|
374
380
|
handler: async (a) => { await queueCommand(a); },
|
|
375
381
|
usage: "<work|stats|retry|clear> [topic]",
|
|
@@ -37,8 +37,8 @@ import { join, relative, resolve, sep } from "node:path";
|
|
|
37
37
|
|
|
38
38
|
// ── Field type mapping ──────────────────────────────────────────────
|
|
39
39
|
const FIELD_TYPE_MAP: Record<string, { orm: string; sql: string; defaultVal: string }> = {
|
|
40
|
-
string: { orm: '"string"', sql: "
|
|
41
|
-
str: { orm: '"string"', sql: "
|
|
40
|
+
string: { orm: '"string"', sql: "VARCHAR(255)", defaultVal: "''" },
|
|
41
|
+
str: { orm: '"string"', sql: "VARCHAR(255)", defaultVal: "''" },
|
|
42
42
|
int: { orm: '"integer"', sql: "INTEGER", defaultVal: "0" },
|
|
43
43
|
integer: { orm: '"integer"', sql: "INTEGER", defaultVal: "0" },
|
|
44
44
|
float: { orm: '"number"', sql: "REAL", defaultVal: "0" },
|
|
@@ -48,7 +48,7 @@ const FIELD_TYPE_MAP: Record<string, { orm: string; sql: string; defaultVal: str
|
|
|
48
48
|
bool: { orm: '"boolean"', sql: "INTEGER", defaultVal: "0" },
|
|
49
49
|
boolean: { orm: '"boolean"', sql: "INTEGER", defaultVal: "0" },
|
|
50
50
|
text: { orm: '"string"', sql: "TEXT", defaultVal: "''" },
|
|
51
|
-
datetime: { orm: '"datetime"', sql: "
|
|
51
|
+
datetime: { orm: '"datetime"', sql: "TIMESTAMP", defaultVal: "NULL" },
|
|
52
52
|
blob: { orm: '"string"', sql: "BLOB", defaultVal: "NULL" },
|
|
53
53
|
};
|
|
54
54
|
|
|
@@ -131,13 +131,78 @@ export function toTableName(name: string): string {
|
|
|
131
131
|
from: raw,
|
|
132
132
|
to: safe,
|
|
133
133
|
reason: `SQL reserved word '${raw}' would break CREATE TABLE`,
|
|
134
|
-
override: `--table
|
|
134
|
+
override: `--table-name <name> (table names interpolate unquoted; forcing a reserved name is yours to quote in raw SQL)`,
|
|
135
135
|
});
|
|
136
136
|
return safe;
|
|
137
137
|
}
|
|
138
138
|
return raw;
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/**
|
|
142
|
+
* The table name a generator uses (issue #123) — honours `--table-name` and
|
|
143
|
+
* speaks up instead of renaming SILENTLY. Mirrors the Python master's
|
|
144
|
+
* `_resolve_table` (tina4-python/tina4_python/cli/__init__.py).
|
|
145
|
+
*
|
|
146
|
+
* `announce` prints the note/warning; it is TRUE only for `generateModel` (where
|
|
147
|
+
* the table is born). Composite generators (crud) let the model sub-call
|
|
148
|
+
* announce, and generators that target an EXISTING table (route/seeder/form/view/
|
|
149
|
+
* migration) still honour `--table-name` but stay quiet so the note is not
|
|
150
|
+
* repeated — the note prints exactly once per `generate`.
|
|
151
|
+
*
|
|
152
|
+
* • `--table-name <name>` wins verbatim. If that name is ITSELF a reserved word,
|
|
153
|
+
* warn loudly (when announcing): Tina4 interpolates table names UNQUOTED, so
|
|
154
|
+
* the ORM's generated SQL will fail on it — quoting it in raw SQL + migrations
|
|
155
|
+
* is now the developer's job (we do NOT silently quote; identifier quoting is a
|
|
156
|
+
* global storage invariant, not a local fix).
|
|
157
|
+
* • Otherwise fall back to `toTableName` (snake + reserved-word pluralise). When
|
|
158
|
+
* that auto-pluralises a reserved-word class name (`Order` -> `orders`), print a
|
|
159
|
+
* one-line NOTE (when announcing) naming the rename and the `--table-name`
|
|
160
|
+
* escape hatch, so the developer is informed rather than surprised.
|
|
161
|
+
*
|
|
162
|
+
* The note/warning goes to STDERR (console.error) so a `generate … --json` run
|
|
163
|
+
* keeps its stdout envelope pristine for a downstream `| jq`. `toTableName`'s
|
|
164
|
+
* `reserved_word_pluralize` envelope transformation is UNCHANGED (this ADDS the
|
|
165
|
+
* announce path; it does not touch the envelope contract).
|
|
166
|
+
*/
|
|
167
|
+
export function resolveTable(
|
|
168
|
+
name: string,
|
|
169
|
+
flags: Record<string, string | boolean> | undefined,
|
|
170
|
+
opts: { announce?: boolean } = {},
|
|
171
|
+
): string {
|
|
172
|
+
const announce = opts.announce ?? false;
|
|
173
|
+
const override = (flags ?? {})["table-name"];
|
|
174
|
+
|
|
175
|
+
// `--table-name <name>` wins verbatim. A bare `--table-name` (no value) parses
|
|
176
|
+
// to `true` — ignore it, exactly like the Python master (falls through to the
|
|
177
|
+
// pluralise path), rather than letting the boolean become the table name.
|
|
178
|
+
if (typeof override === "string" && override) {
|
|
179
|
+
if (announce && SQL_RESERVED_TABLE_NAMES.has(toSnake(override))) {
|
|
180
|
+
console.error(
|
|
181
|
+
` ! table_name '${override}' is a SQL reserved word. Tina4 interpolates ` +
|
|
182
|
+
`table names UNQUOTED, so the ORM's generated SQL will fail on it -- ` +
|
|
183
|
+
`quote it yourself in raw SQL and migrations.`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return override;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const bare = toSnake(name);
|
|
190
|
+
if (!SQL_RESERVED_TABLE_NAMES.has(bare)) {
|
|
191
|
+
return bare; // non-reserved: singular, silent, no envelope transformation
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Reserved: pluralise via toTableName (which records the envelope
|
|
195
|
+
// transformation), then announce the rename so it is never a surprise.
|
|
196
|
+
const table = toTableName(name);
|
|
197
|
+
if (announce) {
|
|
198
|
+
console.error(
|
|
199
|
+
` · '${bare}' is a SQL reserved word; using table_name '${table}' ` +
|
|
200
|
+
`(Tina4 interpolates table names unquoted). Override with --table-name <name>.`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
return table;
|
|
204
|
+
}
|
|
205
|
+
|
|
141
206
|
// ── Resolution surface — the machine-readable envelope every generator ─
|
|
142
207
|
// populates so `--json` can print it and a human run can print the same
|
|
143
208
|
// facts to stderr. See the JSDoc on `printResolution` below for the envelope
|
|
@@ -385,10 +450,11 @@ function printResolution(): void {
|
|
|
385
450
|
lines.push(` migration ${b.migration_path}`);
|
|
386
451
|
}
|
|
387
452
|
const reserved = b.transformations.find((t) => t.kind === "reserved_word_pluralize");
|
|
388
|
-
if (reserved && reserved.from
|
|
453
|
+
if (reserved && reserved.from) {
|
|
389
454
|
lines.push("");
|
|
390
|
-
lines.push(` To
|
|
391
|
-
lines.push(` tina4nodejs generate ${__resolution.target} ${__resolution.input.name}
|
|
455
|
+
lines.push(` To set the table name yourself:`);
|
|
456
|
+
lines.push(` tina4nodejs generate ${__resolution.target} ${__resolution.input.name} --table-name <name>`);
|
|
457
|
+
lines.push(` Tina4 interpolates table names unquoted; if you force the reserved '${reserved.from}', you own the quoting in raw SQL.`);
|
|
392
458
|
}
|
|
393
459
|
// v1.1 (ADR-0063): surface the already-populated test_paths[], and the two
|
|
394
460
|
// new arrays (edit_hints, next) when either is non-empty. Sections stay
|
|
@@ -591,7 +657,7 @@ export interface GeneratorSpec {
|
|
|
591
657
|
}
|
|
592
658
|
|
|
593
659
|
export const GENERATORS: Record<string, GeneratorSpec> = {
|
|
594
|
-
model: { handler: generateModel, usage: '<Name> [--fields "name:string,price:float"]', summary: "ORM model + matching migration" },
|
|
660
|
+
model: { handler: generateModel, usage: '<Name> [--fields "name:string,price:float"] [--table-name <name>]', summary: "ORM model + matching migration" },
|
|
595
661
|
route: { handler: generateRoute, usage: "<name> [--model Name] [--public]", summary: "CRUD route file, secure by default (--public opens writes)" },
|
|
596
662
|
crud: { handler: generateCrud, usage: '<Name> [--fields "..."] [--public]', summary: "Model + migration + routes + form + view + test" },
|
|
597
663
|
migration: { handler: (n, f) => generateMigration(n, f, undefined, undefined, !f["no-test"]), usage: "<description>", summary: "Timestamped migration file (UP/DOWN)" },
|
|
@@ -717,6 +783,7 @@ export async function generate(what: string, name: string, extraArgs: string[] =
|
|
|
717
783
|
console.error(" Usage: tina4nodejs generate <what> <name> [options]");
|
|
718
784
|
console.error(` Generators: ${GENERATOR_LIST}`);
|
|
719
785
|
console.error(' Options: --fields "name:string,price:float" --model ModelName');
|
|
786
|
+
console.error(" --table-name <name> force the model's table name (else derived; reserved words auto-pluralise)");
|
|
720
787
|
console.error(" --public open a route's writes (default: secure)");
|
|
721
788
|
console.error(' --every 5m | --cron "…" service schedule');
|
|
722
789
|
console.error(" --json emit machine-readable resolution envelope on stdout");
|
|
@@ -833,7 +900,9 @@ export async function generateProgrammatic(
|
|
|
833
900
|
|
|
834
901
|
function generateModel(name: string, flags: Record<string, string | boolean>, emitTest = true): void {
|
|
835
902
|
const fields = fieldsOrDefault((flags.fields as string) || "");
|
|
836
|
-
|
|
903
|
+
// The table is BORN here — announce=true, so a reserved-word rename (Order ->
|
|
904
|
+
// orders) or a forced-reserved --table-name is said out loud, not silent (#123).
|
|
905
|
+
const table = resolveTable(name, flags, { announce: true });
|
|
837
906
|
const dir = resolve("src/models");
|
|
838
907
|
ensureDir(dir);
|
|
839
908
|
const path = join(dir, `${name}.ts`);
|
|
@@ -922,7 +991,8 @@ function generateRoute(name: string, flags: Record<string, string | boolean>, em
|
|
|
922
991
|
setResolutionField("file_path", `src/routes/api/${routePath}/get.ts`);
|
|
923
992
|
}
|
|
924
993
|
|
|
925
|
-
|
|
994
|
+
// Route targets an EXISTING table — honour --table-name, stay quiet (announce=false).
|
|
995
|
+
const table = model ? resolveTable(model, flags, { announce: false }) : "";
|
|
926
996
|
// Model import path is RELATIVE to the route file's directory. Files directly
|
|
927
997
|
// under src/routes/api/<name>/ are 3 levels above src/models/; the [id]/ files
|
|
928
998
|
// are one deeper (4 levels).
|
|
@@ -1161,7 +1231,9 @@ ${aiFill(`delete_${singular}`, {
|
|
|
1161
1231
|
// ── CRUD ────────────────────────────────────────────────────────────
|
|
1162
1232
|
|
|
1163
1233
|
function generateCrud(name: string, flags: Record<string, string | boolean>): void {
|
|
1164
|
-
|
|
1234
|
+
// Composite: quiet here (announce=false); the generateModel sub-call below is
|
|
1235
|
+
// the one that announces, so the reserved-word note prints exactly once.
|
|
1236
|
+
const table = resolveTable(name, flags, { announce: false });
|
|
1165
1237
|
const routeName = toPlural(table);
|
|
1166
1238
|
const isPublic = Boolean(flags.public);
|
|
1167
1239
|
|
|
@@ -1221,9 +1293,11 @@ export function generateMigration(
|
|
|
1221
1293
|
.replace(/^create_/, "")
|
|
1222
1294
|
.replace(/^add_/, "")
|
|
1223
1295
|
.replace(/^drop_/, "");
|
|
1224
|
-
//
|
|
1225
|
-
//
|
|
1226
|
-
|
|
1296
|
+
// resolveTable honours --table-name and (via toTableName) records the
|
|
1297
|
+
// `reserved_word_pluralize` transformation when the raw form collides with a
|
|
1298
|
+
// SQL reserved word. Quiet (announce=false): a direct migration targets an
|
|
1299
|
+
// existing table, so it does not repeat the model's note.
|
|
1300
|
+
table = resolveTable(raw, flags, { announce: false });
|
|
1227
1301
|
}
|
|
1228
1302
|
// Only set table_name / migration_path when THIS is the top-level target.
|
|
1229
1303
|
// A migration produced by generateModel is a side effect of `generate model`,
|
|
@@ -1269,7 +1343,7 @@ export function generateMigration(
|
|
|
1269
1343
|
const defaultClause = info.defaultVal !== "NULL" ? ` DEFAULT ${info.defaultVal}` : "";
|
|
1270
1344
|
colLines.push(` ${fname} ${info.sql}${defaultClause}`);
|
|
1271
1345
|
}
|
|
1272
|
-
colLines.push(" created_at
|
|
1346
|
+
colLines.push(" created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP");
|
|
1273
1347
|
|
|
1274
1348
|
upSql =
|
|
1275
1349
|
`CREATE TABLE IF NOT EXISTS ${table} (\n` +
|
|
@@ -1508,7 +1582,7 @@ void test${titleName};
|
|
|
1508
1582
|
|
|
1509
1583
|
function generateForm(name: string, flags: Record<string, string | boolean>): void {
|
|
1510
1584
|
const fields = fieldsOrDefault((flags.fields as string) || "");
|
|
1511
|
-
const table =
|
|
1585
|
+
const table = resolveTable(name, flags, { announce: false });
|
|
1512
1586
|
const routeName = toPlural(table);
|
|
1513
1587
|
|
|
1514
1588
|
const inputTypes: Record<string, string> = {
|
|
@@ -1586,7 +1660,7 @@ function generateForm(name: string, flags: Record<string, string | boolean>): vo
|
|
|
1586
1660
|
|
|
1587
1661
|
function generateView(name: string, flags: Record<string, string | boolean>): void {
|
|
1588
1662
|
const fields = fieldsOrDefault((flags.fields as string) || "");
|
|
1589
|
-
const table =
|
|
1663
|
+
const table = resolveTable(name, flags, { announce: false });
|
|
1590
1664
|
const routeName = toPlural(table);
|
|
1591
1665
|
|
|
1592
1666
|
const cols = fields.map(([f]) => f);
|
|
@@ -2025,8 +2099,8 @@ ${rules} validator.required("name"); // starter rule (matches the model's def
|
|
|
2025
2099
|
// overrides may be static values OR (fake) => value callables) and
|
|
2026
2100
|
// packages/cli/src/commands/seed.ts (runs each src/seeds/*.ts as a script).
|
|
2027
2101
|
|
|
2028
|
-
function generateSeeder(name: string,
|
|
2029
|
-
const table =
|
|
2102
|
+
function generateSeeder(name: string, flags: Record<string, string | boolean>): void {
|
|
2103
|
+
const table = resolveTable(name, flags, { announce: false });
|
|
2030
2104
|
const dir = resolve("src/seeds");
|
|
2031
2105
|
ensureDir(dir);
|
|
2032
2106
|
const path = join(dir, `${table}_seeder.ts`);
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: lint — lint the project's source. The FRAMEWORK ships no linter
|
|
3
|
+
* (a Tina4 app stays zero-dependency); `tina4nodejs lint` uses the project's own
|
|
4
|
+
* eslint and INSTALLS it as a DEV dependency of the PROJECT on demand when it is
|
|
5
|
+
* absent — running the command is the consent. Layers, in order:
|
|
6
|
+
*
|
|
7
|
+
* • eslint present (resolvable from the project's node_modules) AND a flat
|
|
8
|
+
* config present (`eslint.config.{js,mjs,cjs,ts,mts,cts}`): run it. `--fix`
|
|
9
|
+
* runs `eslint --fix` (safe autofixes). eslint reports syntax too, so it is
|
|
10
|
+
* the whole pass when present.
|
|
11
|
+
* • eslint absent (and NOT `--no-install`): silently `npm i -D eslint
|
|
12
|
+
* @eslint/js typescript-eslint` into the PROJECT (dev-only, never the app's
|
|
13
|
+
* runtime deps — typescript-eslint pulls its own `typescript` peer), and
|
|
14
|
+
* scaffold a minimal flat config at `eslint.config.js` when none exists that
|
|
15
|
+
* lints BOTH `.js` and `.ts`, then run eslint. A one-line ` · installing
|
|
16
|
+
* eslint...` notice is printed. If npm is missing or the install fails, a
|
|
17
|
+
* one-line notice is printed and the command falls through to the baseline.
|
|
18
|
+
* • Baseline (zero new dependency): `tsc --noEmit` when a `tsconfig.json`
|
|
19
|
+
* exists (the type+syntax check every tina4-nodejs project already owns,
|
|
20
|
+
* forced to emit nothing); otherwise stdlib `node --check` over `.js`/`.mjs`/
|
|
21
|
+
* `.cjs` files. Used with `--no-install`, or when the install cannot run.
|
|
22
|
+
*
|
|
23
|
+
* Contract (identical across all four frameworks): exit 0 = clean, non-zero =
|
|
24
|
+
* findings; the summary names the tool that ran in `[...]`; `--fix` only
|
|
25
|
+
* autofixes on the eslint path. Scope is the user's app (`src/` + the entrypoint
|
|
26
|
+
* `app.ts`/`app.js`), mirroring how `tina4nodejs test` runs the project's own
|
|
27
|
+
* tests — not the framework's code.
|
|
28
|
+
*
|
|
29
|
+
* tina4nodejs lint # eslint (installed dev-only on demand), else baseline
|
|
30
|
+
* tina4nodejs lint --fix # eslint --fix
|
|
31
|
+
* tina4nodejs lint --no-install # eslint if already present, else the baseline
|
|
32
|
+
*
|
|
33
|
+
* Mirrors the Python master's _lint (tina4_python/cli/__init__.py) — ruff there,
|
|
34
|
+
* eslint here.
|
|
35
|
+
*/
|
|
36
|
+
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
37
|
+
import { createRequire } from "node:module";
|
|
38
|
+
import { delimiter, dirname, join, relative } from "node:path";
|
|
39
|
+
import { spawnSync } from "node:child_process";
|
|
40
|
+
|
|
41
|
+
// Source extensions we count as lintable app code. `.d.ts` declaration files are
|
|
42
|
+
// deliberately excluded — they carry no runnable code for `node --check` and are
|
|
43
|
+
// covered by the tsconfig for `tsc`.
|
|
44
|
+
const LINT_EXTENSIONS = [".ts", ".mts", ".cts", ".js", ".mjs", ".cjs"];
|
|
45
|
+
// The subset `node --check` can parse (it does not understand TypeScript).
|
|
46
|
+
const JS_EXTENSIONS = [".js", ".mjs", ".cjs"];
|
|
47
|
+
// Directories a source walk must never descend into.
|
|
48
|
+
const SKIP_DIRS = new Set(["node_modules", "dist", ".git"]);
|
|
49
|
+
|
|
50
|
+
/** True for a lintable source filename (excludes `.d.ts`). */
|
|
51
|
+
function isLintFile(name: string): boolean {
|
|
52
|
+
if (name.endsWith(".d.ts")) return false;
|
|
53
|
+
return LINT_EXTENSIONS.some((ext) => name.endsWith(ext));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Recursively collect lintable source files under `dir`. */
|
|
57
|
+
function walkSource(dir: string, out: string[]): void {
|
|
58
|
+
let entries: string[];
|
|
59
|
+
try {
|
|
60
|
+
entries = readdirSync(dir);
|
|
61
|
+
} catch {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
for (const name of entries) {
|
|
65
|
+
if (SKIP_DIRS.has(name)) continue;
|
|
66
|
+
const full = join(dir, name);
|
|
67
|
+
let st;
|
|
68
|
+
try {
|
|
69
|
+
st = statSync(full);
|
|
70
|
+
} catch {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (st.isDirectory()) {
|
|
74
|
+
walkSource(full, out);
|
|
75
|
+
} else if (isLintFile(name)) {
|
|
76
|
+
out.push(full);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The user's app source in lint scope: everything under `src/` plus the
|
|
83
|
+
* entrypoint (`app.ts`/`app.js`, with `.mts`/`.mjs` variants). Mirrors how the
|
|
84
|
+
* Python master lints `src/` + `app.py` — the developer's code, never the
|
|
85
|
+
* framework's.
|
|
86
|
+
*/
|
|
87
|
+
function collectAppFiles(cwd: string): string[] {
|
|
88
|
+
const files: string[] = [];
|
|
89
|
+
const srcDir = join(cwd, "src");
|
|
90
|
+
try {
|
|
91
|
+
if (statSync(srcDir).isDirectory()) walkSource(srcDir, files);
|
|
92
|
+
} catch {
|
|
93
|
+
// no src/ — fall through to the entrypoint check
|
|
94
|
+
}
|
|
95
|
+
for (const entry of ["app.ts", "app.mts", "app.js", "app.mjs"]) {
|
|
96
|
+
const p = join(cwd, entry);
|
|
97
|
+
try {
|
|
98
|
+
if (statSync(p).isFile()) files.push(p);
|
|
99
|
+
} catch {
|
|
100
|
+
// not present
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return files;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Flat-config filenames to DETECT (any one means the project already configured
|
|
108
|
+
* eslint, so we never scaffold over it). Legacy `.eslintrc*` is deliberately NOT
|
|
109
|
+
* detected: eslint 9 ignores it under flat config, so treating it as "configured"
|
|
110
|
+
* would make eslint run and then error "no flat config found". Flat config only.
|
|
111
|
+
*/
|
|
112
|
+
const ESLINT_FLAT_CONFIGS = [
|
|
113
|
+
"eslint.config.js",
|
|
114
|
+
"eslint.config.mjs",
|
|
115
|
+
"eslint.config.cjs",
|
|
116
|
+
"eslint.config.ts",
|
|
117
|
+
"eslint.config.mts",
|
|
118
|
+
"eslint.config.cts",
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
// The filename we SCAFFOLD when a project has no flat config. `.js` (not `.mjs`)
|
|
122
|
+
// per the shared design; every tina4-nodejs project is `"type": "module"`, so a
|
|
123
|
+
// `.js` flat config is ESM and loads as written.
|
|
124
|
+
const ESLINT_SCAFFOLD_FILE = "eslint.config.js";
|
|
125
|
+
|
|
126
|
+
// The minimal flat config we scaffold — eslint's own recommended rule set PLUS
|
|
127
|
+
// typescript-eslint's recommended (the non-type-checked / syntactic preset, so no
|
|
128
|
+
// tsconfig or parserOptions.project wiring is needed) so BOTH `.js` and `.ts` are
|
|
129
|
+
// linted. Without the typescript-eslint half, eslint reports every `.ts` file
|
|
130
|
+
// "File ignored" and a TS project lints vacuously. IDENTICAL content across the
|
|
131
|
+
// shared design.
|
|
132
|
+
const ESLINT_SCAFFOLD = `import js from "@eslint/js";
|
|
133
|
+
import tseslint from "typescript-eslint";
|
|
134
|
+
export default [js.configs.recommended, ...tseslint.configs.recommended];
|
|
135
|
+
`;
|
|
136
|
+
|
|
137
|
+
/** Absolute path of an eslint flat config in `cwd`, or null when none exists. */
|
|
138
|
+
function findEslintConfig(cwd: string): string | null {
|
|
139
|
+
for (const name of ESLINT_FLAT_CONFIGS) {
|
|
140
|
+
const p = join(cwd, name);
|
|
141
|
+
if (existsSync(p)) return p;
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Resolve a package's bin script FROM the user's project, or null when the
|
|
148
|
+
* package is not installed there.
|
|
149
|
+
*
|
|
150
|
+
* The DIRECT `node_modules/<pkg>/<bin>` path is checked FIRST because it is
|
|
151
|
+
* immune to the CJS resolver's caches: `runLint` calls this both BEFORE and
|
|
152
|
+
* AFTER an in-process `npm install`, and Node can negatively-cache the pre-install
|
|
153
|
+
* "not found" so a post-install `require.resolve` still misses (the same
|
|
154
|
+
* dir-listing-cache footgun Python's importlib has). `existsSync` hits the real
|
|
155
|
+
* filesystem every call, so it sees the just-installed bin. The resolver is the
|
|
156
|
+
* fallback for hoisted / workspace layouts where the package is not a direct
|
|
157
|
+
* child of `cwd/node_modules`.
|
|
158
|
+
*/
|
|
159
|
+
function resolvePackageBin(cwd: string, pkg: string, binRelative: string): string | null {
|
|
160
|
+
const direct = join(cwd, "node_modules", pkg, binRelative);
|
|
161
|
+
if (existsSync(direct)) return direct;
|
|
162
|
+
|
|
163
|
+
const require = createRequire(join(cwd, "package.json"));
|
|
164
|
+
let entry: string;
|
|
165
|
+
try {
|
|
166
|
+
entry = require.resolve(pkg);
|
|
167
|
+
} catch {
|
|
168
|
+
return null; // not installed / not resolvable from the project
|
|
169
|
+
}
|
|
170
|
+
let dir = dirname(entry);
|
|
171
|
+
for (let i = 0; i < 8; i++) {
|
|
172
|
+
const pkgJson = join(dir, "package.json");
|
|
173
|
+
if (existsSync(pkgJson)) {
|
|
174
|
+
try {
|
|
175
|
+
if (JSON.parse(readFileSync(pkgJson, "utf-8")).name === pkg) {
|
|
176
|
+
const bin = join(dir, binRelative);
|
|
177
|
+
return existsSync(bin) ? bin : null;
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
// malformed package.json — keep walking up
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const parent = dirname(dir);
|
|
184
|
+
if (parent === dir) break;
|
|
185
|
+
dir = parent;
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** eslint's runnable bin from the project (`bin/eslint.js`), or null. */
|
|
191
|
+
function resolveEslintBin(cwd: string): string | null {
|
|
192
|
+
return resolvePackageBin(cwd, "eslint", "bin/eslint.js");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* True when `pkg` is installed directly under the project's node_modules (a
|
|
197
|
+
* `package.json` at `node_modules/<pkg>/`). Direct filesystem check, immune to
|
|
198
|
+
* the module-resolver cache — the scaffold imports `@eslint/js` and
|
|
199
|
+
* `typescript-eslint`, so it must not be written until both are on disk.
|
|
200
|
+
*/
|
|
201
|
+
function hasPackage(cwd: string, pkg: string): boolean {
|
|
202
|
+
return existsSync(join(cwd, "node_modules", ...pkg.split("/"), "package.json"));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Absolute path of `npm` on PATH, or null. Scanned directly (no `which` shell-out)
|
|
207
|
+
* so it behaves the same on every platform — mirrors bin.ts's findClient().
|
|
208
|
+
*/
|
|
209
|
+
function resolveNpm(): string | null {
|
|
210
|
+
const windows = process.platform === "win32";
|
|
211
|
+
const names = windows ? ["npm.cmd", "npm.exe", "npm"] : ["npm"];
|
|
212
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
213
|
+
if (!dir) continue;
|
|
214
|
+
for (const name of names) {
|
|
215
|
+
const candidate = join(dir, name);
|
|
216
|
+
try {
|
|
217
|
+
if (statSync(candidate).isFile()) return candidate;
|
|
218
|
+
} catch {
|
|
219
|
+
// not there — keep looking
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Lint the project's source. Exits 0 = clean, 1 = findings (parity with the
|
|
228
|
+
* Python master and with `tina4nodejs test`'s exit-code contract).
|
|
229
|
+
*/
|
|
230
|
+
export function runLint(args: string[]): void {
|
|
231
|
+
const fix = args.includes("--fix");
|
|
232
|
+
const noInstall = args.includes("--no-install");
|
|
233
|
+
const cwd = process.cwd();
|
|
234
|
+
|
|
235
|
+
const files = collectAppFiles(cwd);
|
|
236
|
+
if (files.length === 0) {
|
|
237
|
+
console.log(" lint: nothing to lint (no src/ or app.ts).");
|
|
238
|
+
process.exit(0);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let eslintBin = resolveEslintBin(cwd);
|
|
242
|
+
let eslintConfig = findEslintConfig(cwd);
|
|
243
|
+
|
|
244
|
+
// ── Silent on-demand bootstrap ──────────────────────────────────────
|
|
245
|
+
// Running `tina4 lint` is the consent to add eslint as a DEV dependency of the
|
|
246
|
+
// PROJECT and scaffold a minimal flat config. --no-install opts out (CI /
|
|
247
|
+
// offline) and falls through to the zero-dependency baseline. We only bootstrap
|
|
248
|
+
// what is missing, and only scaffold a config once eslint is actually available
|
|
249
|
+
// to use it (so a failed install never leaves a stray eslint.config.js).
|
|
250
|
+
if (!noInstall && (!eslintBin || !eslintConfig)) {
|
|
251
|
+
const npm = resolveNpm();
|
|
252
|
+
if (!npm) {
|
|
253
|
+
console.log(" · npm not found — using the zero-dependency baseline.");
|
|
254
|
+
} else {
|
|
255
|
+
if (!eslintBin) {
|
|
256
|
+
console.log(" · installing eslint (npm i -D eslint @eslint/js typescript-eslint)...");
|
|
257
|
+
const rc = spawnSync(npm, ["install", "-D", "eslint", "@eslint/js", "typescript-eslint"], {
|
|
258
|
+
cwd,
|
|
259
|
+
stdio: "inherit",
|
|
260
|
+
}).status;
|
|
261
|
+
if (rc === 0) {
|
|
262
|
+
eslintBin = resolveEslintBin(cwd);
|
|
263
|
+
} else {
|
|
264
|
+
console.log(" · could not install eslint — using the zero-dependency baseline.");
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// Scaffold ONLY once all three the config imports are on disk (eslint bin +
|
|
268
|
+
// @eslint/js + typescript-eslint), so a partial/failed install never leaves
|
|
269
|
+
// an eslint.config.js that cannot load.
|
|
270
|
+
if (
|
|
271
|
+
eslintBin && !eslintConfig &&
|
|
272
|
+
hasPackage(cwd, "@eslint/js") && hasPackage(cwd, "typescript-eslint")
|
|
273
|
+
) {
|
|
274
|
+
const scaffold = join(cwd, ESLINT_SCAFFOLD_FILE);
|
|
275
|
+
try {
|
|
276
|
+
writeFileSync(scaffold, ESLINT_SCAFFOLD, "utf-8");
|
|
277
|
+
eslintConfig = scaffold;
|
|
278
|
+
console.log(` · scaffolded ${ESLINT_SCAFFOLD_FILE} (@eslint/js + typescript-eslint recommended).`);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
console.log(
|
|
281
|
+
` · could not scaffold ${ESLINT_SCAFFOLD_FILE} (${err instanceof Error ? err.message : String(err)}).`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── eslint: the project's own linter (installed dev-only on demand) ──
|
|
289
|
+
// Only when a config exists AND eslint resolves from the project. eslint reports
|
|
290
|
+
// syntax errors too, so when present it is the entire pass.
|
|
291
|
+
if (eslintBin && eslintConfig) {
|
|
292
|
+
const label = fix ? "eslint --fix" : "eslint";
|
|
293
|
+
const eslintArgs = [eslintBin, ...files, ...(fix ? ["--fix"] : [])];
|
|
294
|
+
const code = spawnSync(process.execPath, eslintArgs, { cwd, stdio: "inherit" }).status ?? 1;
|
|
295
|
+
if (code !== 0) {
|
|
296
|
+
console.log(` ✗ lint failed — ${files.length} file(s) [${label}]`);
|
|
297
|
+
process.exit(1);
|
|
298
|
+
}
|
|
299
|
+
console.log(` ✓ lint clean — ${files.length} file(s) [${label}]`);
|
|
300
|
+
process.exit(0);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// From here on nothing can autofix — say so once when --fix was asked for.
|
|
304
|
+
if (fix) {
|
|
305
|
+
console.log(" · --fix needs eslint — the baseline check has no autofix.");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── Baseline (TypeScript): the project's own `tsc --noEmit` ──────────
|
|
309
|
+
// Every tina4-nodejs project ships tsconfig.json + typescript. `--noEmit` forces
|
|
310
|
+
// a type+syntax check that writes NOTHING (the project's tsconfig may set outDir,
|
|
311
|
+
// so this must never emit). tsc reports syntax errors too. Run from cwd so tsc
|
|
312
|
+
// reads THIS tsconfig.json; diagnostics stream straight through.
|
|
313
|
+
const hasTsconfig = existsSync(join(cwd, "tsconfig.json"));
|
|
314
|
+
const tscBin = hasTsconfig ? resolvePackageBin(cwd, "typescript", "bin/tsc") : null;
|
|
315
|
+
if (tscBin) {
|
|
316
|
+
const code = spawnSync(process.execPath, [tscBin, "--noEmit"], { cwd, stdio: "inherit" }).status ?? 1;
|
|
317
|
+
if (code !== 0) {
|
|
318
|
+
console.log(` ✗ lint failed — ${files.length} file(s) [tsc]`);
|
|
319
|
+
process.exit(1);
|
|
320
|
+
}
|
|
321
|
+
console.log(` ✓ lint clean — ${files.length} file(s) [tsc]`);
|
|
322
|
+
process.exit(0);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ── Baseline (plain JS): stdlib `node --check` over .js/.mjs/.cjs ────
|
|
326
|
+
// Ships with node — zero dependency. A full syntax parse that never runs the
|
|
327
|
+
// code. TypeScript files are out of its reach (they belong to the tsc path).
|
|
328
|
+
const jsFiles = files.filter((f) => JS_EXTENSIONS.some((ext) => f.endsWith(ext)));
|
|
329
|
+
if (jsFiles.length === 0) {
|
|
330
|
+
console.log(
|
|
331
|
+
" lint: no JavaScript files to check — add tsconfig.json + typescript to type-check .ts files.",
|
|
332
|
+
);
|
|
333
|
+
process.exit(0);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
let syntaxErrors = 0;
|
|
337
|
+
for (const file of jsFiles) {
|
|
338
|
+
const result = spawnSync(process.execPath, ["--check", file], { cwd, encoding: "utf-8" });
|
|
339
|
+
if ((result.status ?? 1) !== 0) {
|
|
340
|
+
const stderr = (result.stderr || "").trim();
|
|
341
|
+
// node --check ends its stderr with a `SyntaxError: ...` line — surface it.
|
|
342
|
+
const detail =
|
|
343
|
+
stderr
|
|
344
|
+
.split("\n")
|
|
345
|
+
.reverse()
|
|
346
|
+
.find((line) => line.includes("Error:")) || "syntax error";
|
|
347
|
+
console.log(` ✗ ${relative(cwd, file)}: ${detail.trim()}`);
|
|
348
|
+
syntaxErrors++;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (syntaxErrors > 0) {
|
|
353
|
+
console.log(
|
|
354
|
+
` ✗ lint failed — ${syntaxErrors} syntax error(s) in ${jsFiles.length} file(s) [node --check]`,
|
|
355
|
+
);
|
|
356
|
+
process.exit(1);
|
|
357
|
+
}
|
|
358
|
+
console.log(` ✓ lint clean — ${jsFiles.length} file(s) [node --check]`);
|
|
359
|
+
process.exit(0);
|
|
360
|
+
}
|