tina4-nodejs 3.13.118 → 3.13.120

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 CHANGED
@@ -13,13 +13,13 @@ Even if the skill text is not currently loaded, these are non-negotiable:
13
13
 
14
14
  The full discipline lives in `.claude/skills/tina4-maintainer/SKILL.md`; this block is the always-on floor.
15
15
 
16
- # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.118)
16
+ # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.120)
17
17
 
18
18
  > This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
19
19
 
20
20
  ## What This Project Is
21
21
 
22
- Tina4 for Node.js/TypeScript v3.13.118 - The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
22
+ Tina4 for Node.js/TypeScript v3.13.120 - The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
23
23
 
24
24
  The philosophy: zero ceremony, batteries included, file system as source of truth.
25
25
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.13.118",
3
+ "version": "3.13.120",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript - native TypeScript conventions and shared Tina4 contracts",
6
6
  "keywords": [
@@ -1832,15 +1832,15 @@ function findOutsideQuotes(expr, needle) {
1832
1832
  }
1833
1833
  return -1;
1834
1834
  }
1835
- function splitOutsideQuotes(expr, sep6) {
1836
- if (!expr.includes(sep6)) return [expr];
1835
+ function splitOutsideQuotes(expr, sep7) {
1836
+ if (!expr.includes(sep7)) return [expr];
1837
1837
  const parts = [];
1838
1838
  let currentStart = 0;
1839
1839
  let inQuote = null;
1840
1840
  let depth = 0;
1841
1841
  let bracketDepth = 0;
1842
1842
  let i = 0;
1843
- const sepLen = sep6.length;
1843
+ const sepLen = sep7.length;
1844
1844
  const lastStart = expr.length - sepLen;
1845
1845
  while (i <= lastStart) {
1846
1846
  const ch = expr[i];
@@ -1861,7 +1861,7 @@ function splitOutsideQuotes(expr, sep6) {
1861
1861
  else if (ch === ")") depth--;
1862
1862
  else if (ch === "[") bracketDepth++;
1863
1863
  else if (ch === "]") bracketDepth--;
1864
- if (depth === 0 && bracketDepth === 0 && expr.startsWith(sep6, i)) {
1864
+ if (depth === 0 && bracketDepth === 0 && expr.startsWith(sep7, i)) {
1865
1865
  parts.push(expr.slice(currentStart, i));
1866
1866
  i += sepLen;
1867
1867
  currentStart = i;
@@ -2637,8 +2637,8 @@ var init_engine = __esm({
2637
2637
  },
2638
2638
  first: (v) => Array.isArray(v) ? v[0] ?? null : null,
2639
2639
  last: (v) => Array.isArray(v) ? v[v.length - 1] ?? null : null,
2640
- join: (v, sep6) => Array.isArray(v) ? v.map(String).join(sep6 !== void 0 ? String(sep6) : ", ") : String(v),
2641
- split: (v, sep6) => String(v).split(sep6 !== void 0 ? String(sep6) : " "),
2640
+ join: (v, sep7) => Array.isArray(v) ? v.map(String).join(sep7 !== void 0 ? String(sep7) : ", ") : String(v),
2641
+ split: (v, sep7) => String(v).split(sep7 !== void 0 ? String(sep7) : " "),
2642
2642
  replace: (v, from, to) => {
2643
2643
  const s = String(v);
2644
2644
  if (from !== void 0 && typeof from === "object" && from !== null && !Array.isArray(from)) {
@@ -36205,8 +36205,8 @@ function writeMcpDiscovery(projectRoot3, port) {
36205
36205
  const lines = contents.split(/\r?\n/);
36206
36206
  const already = lines.some((l) => l.trim() === GITIGNORE_LINE || l.trim() === ".tina4");
36207
36207
  if (!already) {
36208
- const sep6 = contents.endsWith("\n") || contents === "" ? "" : "\n";
36209
- fs8.writeFileSync(gitignorePath, `${contents}${sep6}${GITIGNORE_LINE}
36208
+ const sep7 = contents.endsWith("\n") || contents === "" ? "" : "\n";
36209
+ fs8.writeFileSync(gitignorePath, `${contents}${sep7}${GITIGNORE_LINE}
36210
36210
  `, "utf-8");
36211
36211
  }
36212
36212
  }
@@ -45400,8 +45400,8 @@ async function runTests(testPath) {
45400
45400
  console.log(` Found ${testFiles.length} test file(s)
45401
45401
  `);
45402
45402
  for (const file of testFiles) {
45403
- const relative10 = file.replace(cwd + "/", "");
45404
- console.log(` Running: ${relative10}`);
45403
+ const relative11 = file.replace(cwd + "/", "");
45404
+ console.log(` Running: ${relative11}`);
45405
45405
  try {
45406
45406
  execSync3(`npx tsx "${file}"`, { cwd, stdio: "inherit" });
45407
45407
  } catch {
@@ -45413,7 +45413,7 @@ async function runTests(testPath) {
45413
45413
 
45414
45414
  // src/commands/generate.ts
45415
45415
  import { existsSync as existsSync33, mkdirSync as mkdirSync23, writeFileSync as writeFileSync20 } from "node:fs";
45416
- import { join as join35, resolve as resolve28 } from "node:path";
45416
+ import { join as join35, relative as relative10, resolve as resolve28, sep as sep6 } from "node:path";
45417
45417
  var FIELD_TYPE_MAP = {
45418
45418
  string: { orm: '"string"', sql: "TEXT", defaultVal: "''" },
45419
45419
  str: { orm: '"string"', sql: "TEXT", defaultVal: "''" },
@@ -45436,6 +45436,7 @@ function ensureDir(dir) {
45436
45436
  }
45437
45437
  }
45438
45438
  function writeFileSafe(path8, content) {
45439
+ captureEditHints(path8, content);
45439
45440
  if (__resolution.dryRun) {
45440
45441
  return;
45441
45442
  }
@@ -45551,7 +45552,7 @@ function toTableName(name) {
45551
45552
  }
45552
45553
  return raw;
45553
45554
  }
45554
- var RESOLUTION_ENVELOPE_VERSION = "generate_v1";
45555
+ var RESOLUTION_ENVELOPE_VERSION = "generate_v1_1";
45555
45556
  var __resolution = {
45556
45557
  target: "",
45557
45558
  input: { name: "", fields: null },
@@ -45572,14 +45573,27 @@ function recordTransformation(t) {
45572
45573
  __resolution.body.transformations.push(t);
45573
45574
  }
45574
45575
  function currentResolution() {
45576
+ const body = {
45577
+ ...__resolution.body,
45578
+ transformations: [...__resolution.body.transformations]
45579
+ };
45580
+ if (__resolution.body.edit_hints) {
45581
+ body.edit_hints = __resolution.body.edit_hints.map((h) => ({ ...h }));
45582
+ }
45583
+ if (__resolution.body.next) {
45584
+ body.next = [...__resolution.body.next];
45585
+ }
45586
+ if (__resolution.body.test_paths) {
45587
+ body.test_paths = [...__resolution.body.test_paths];
45588
+ }
45589
+ if (__resolution.body.routes) {
45590
+ body.routes = [...__resolution.body.routes];
45591
+ }
45575
45592
  return {
45576
45593
  command: "generate",
45577
45594
  target: __resolution.target,
45578
45595
  input: { ...__resolution.input },
45579
- resolution: {
45580
- ...__resolution.body,
45581
- transformations: [...__resolution.body.transformations]
45582
- },
45596
+ resolution: body,
45583
45597
  actions_taken: [...__resolution.actionsTaken],
45584
45598
  dry_run: __resolution.dryRun
45585
45599
  };
@@ -45595,6 +45609,32 @@ function pushTestPath(path8) {
45595
45609
  if (!__resolution.body.test_paths) __resolution.body.test_paths = [];
45596
45610
  __resolution.body.test_paths.push(path8);
45597
45611
  }
45612
+ function pushEditHint(hint) {
45613
+ if (!__resolution.body.edit_hints) __resolution.body.edit_hints = [];
45614
+ __resolution.body.edit_hints.push(hint);
45615
+ }
45616
+ function setNextSteps(steps) {
45617
+ if (steps.length === 0) return;
45618
+ __resolution.body.next = [...steps];
45619
+ }
45620
+ function toRelPath(absPath) {
45621
+ const cwd = process.cwd();
45622
+ const rel = relative10(cwd, absPath);
45623
+ if (!rel) return absPath;
45624
+ return sep6 === "/" ? rel : rel.split(sep6).join("/");
45625
+ }
45626
+ var TINA4_EDIT_MARKER = /^\s*\/\/\s*tina4:edit\s+(.+?)\s*$/;
45627
+ function captureEditHints(absPath, content) {
45628
+ if (!/\.(ts|tsx|js|mjs|cjs|jsx)$/.test(absPath)) return;
45629
+ const relPath = toRelPath(absPath);
45630
+ const lines = content.split("\n");
45631
+ for (let i = 0; i < lines.length; i++) {
45632
+ const match = TINA4_EDIT_MARKER.exec(lines[i]);
45633
+ if (match) {
45634
+ pushEditHint({ file: relPath, line: i + 1, label: match[1].trim() });
45635
+ }
45636
+ }
45637
+ }
45598
45638
  function printResolution() {
45599
45639
  if (__resolution.jsonMode) {
45600
45640
  process.stdout.write(JSON.stringify(currentResolution(), null, 2) + "\n");
@@ -45625,6 +45665,23 @@ function printResolution() {
45625
45665
  lines.push(` To keep the raw name '${reserved.from}' as the table:`);
45626
45666
  lines.push(` tina4nodejs generate ${__resolution.target} ${__resolution.input.name} ${reserved.override}`);
45627
45667
  }
45668
+ if (b.test_paths && b.test_paths.length > 0) {
45669
+ lines.push("");
45670
+ lines.push(" Tests:");
45671
+ for (const testPath of b.test_paths) lines.push(` ${testPath}`);
45672
+ }
45673
+ if (b.edit_hints && b.edit_hints.length > 0) {
45674
+ lines.push("");
45675
+ lines.push(" Edit these lines:");
45676
+ for (const hint of b.edit_hints) {
45677
+ lines.push(` ${hint.file}:${hint.line} ${hint.label}`);
45678
+ }
45679
+ }
45680
+ if (b.next && b.next.length > 0) {
45681
+ lines.push("");
45682
+ lines.push(" Next:");
45683
+ for (const step of b.next) lines.push(` ${step}`);
45684
+ }
45628
45685
  lines.push("");
45629
45686
  process.stderr.write(lines.join("\n"));
45630
45687
  }
@@ -45752,6 +45809,93 @@ var GENERATORS = {
45752
45809
  listener: { handler: generateListener, usage: "<event>", summary: "Events.on(event) listener (src/listeners/)" }
45753
45810
  };
45754
45811
  var GENERATOR_LIST = Object.keys(GENERATORS).join(", ");
45812
+ var NEXT_STEPS = {
45813
+ model: ({ name, table: table2 }) => [
45814
+ `Edit src/models/${name}.ts to add fields beyond the default 'name'`,
45815
+ `Apply the migration: npx tina4nodejs migrate`,
45816
+ `Run its test: npx tsx tests/${table2}_model.test.ts`,
45817
+ `Add CRUD scaffolding: npx tina4nodejs generate crud ${name}`
45818
+ ],
45819
+ route: ({ name, table: table2 }) => [
45820
+ `Fill the AI-FILL stubs in src/routes/api/${name.replace(/^\//, "")}/`,
45821
+ `Run its test: npx tsx tests/${table2}.test.ts`,
45822
+ `Serve and try: npx tina4nodejs serve -> curl http://localhost:7148/api/${name.replace(/^\//, "")}`
45823
+ ],
45824
+ crud: ({ name, table: table2 }) => [
45825
+ `Apply the migration: npx tina4nodejs migrate`,
45826
+ `Serve and try: npx tina4nodejs serve -> visit /swagger`,
45827
+ `Run the gate test: npx tsx tests/${toPlural(table2)}.test.ts`,
45828
+ `Change fields: edit src/models/${name}.ts then re-run generate crud`
45829
+ ],
45830
+ migration: () => [
45831
+ `Apply pending migrations: npx tina4nodejs migrate`,
45832
+ `Check status: npx tina4nodejs migrate:status`,
45833
+ `Roll back the batch: npx tina4nodejs migrate:rollback`
45834
+ ],
45835
+ middleware: ({ name }) => [
45836
+ `Wire it: router.middleware(before${name}, after${name}) \u2014 or bind per-route`,
45837
+ `Run its test: npx tsx tests/${toSnake(name)}.test.ts`
45838
+ ],
45839
+ test: ({ name }) => [
45840
+ `Fill the TODOs in tests/${toSnake(name)}.test.ts`,
45841
+ `Run it: npx tsx tests/${toSnake(name)}.test.ts`
45842
+ ],
45843
+ form: ({ name, table: table2 }) => [
45844
+ `Render from a route: res.render("forms/${table2}.twig", { item })`,
45845
+ `Add the POST route: npx tina4nodejs generate route ${toPlural(table2)} --model ${name}`
45846
+ ],
45847
+ view: ({ table: table2 }) => [
45848
+ `Wire routes to render list -> ${toPlural(table2)}.twig, detail -> ${table2}.twig`,
45849
+ `Customize the templates in src/templates/pages/`
45850
+ ],
45851
+ auth: () => [
45852
+ `Apply the migration: npx tina4nodejs migrate`,
45853
+ `Run the auth test: npx tsx tests/auth.test.ts`,
45854
+ `Try register: curl -X POST http://localhost:7148/api/auth/register -d '{"email":"a@b.c","password":"secret12"}' -H 'content-type: application/json'`,
45855
+ `Login: curl -X POST http://localhost:7148/api/auth/login -d '{"email":"a@b.c","password":"secret12"}' -H 'content-type: application/json'`
45856
+ ],
45857
+ service: ({ name }) => [
45858
+ `Wire ServiceRunner in app.ts: await ServiceRunner.discover("src/services"); ServiceRunner.start();`,
45859
+ `Fill the task body in src/services/${toSnake(name)}.ts`,
45860
+ `Run its test: npx tsx tests/${toSnake(name)}.test.ts`
45861
+ ],
45862
+ queue: ({ name }) => {
45863
+ const slug = toSnake(name.replace(/[^0-9a-zA-Z]+/g, "_")).replace(/^_+|_+$/g, "") || "topic";
45864
+ return [
45865
+ `Fill handle${toPascal(name)}() in src/services/${slug}_consumer.ts`,
45866
+ `Produce a job: publish${toPascal(name)}({ ... })`,
45867
+ `Run the worker: npx tina4nodejs queue work ${name}`,
45868
+ `Run its test: npx tsx tests/${slug}.test.ts`
45869
+ ];
45870
+ },
45871
+ validator: ({ name }) => [
45872
+ `Add rules in src/validators/${toSnake(name)}.ts (.email/.minLength/.integer/.inList/.pattern)`,
45873
+ `Run its test: npx tsx tests/${toSnake(name)}.test.ts`
45874
+ ],
45875
+ seeder: ({ name, table: table2 }) => [
45876
+ `Override any fields that need a specific shape in src/seeds/${table2}_seeder.ts`,
45877
+ `Seed the table: npx tina4nodejs seed`,
45878
+ `Run its test: npx tsx tests/${table2}_seeder.test.ts`
45879
+ ],
45880
+ websocket: ({ name }) => {
45881
+ const raw = name.trim();
45882
+ const slugRaw = toSnake(raw.replace(/^\/+|\/+$/g, "").replace(/[^0-9a-zA-Z]+/g, "_")).replace(/^_+|_+$/g, "") || "ws";
45883
+ const base = slugRaw.startsWith("ws_") ? slugRaw.slice(3) : slugRaw;
45884
+ return [
45885
+ `Import once in app.ts to register: import "./src/routes/ws_${base}.js";`,
45886
+ `Fill the "message" branch in src/routes/ws_${base}.ts`,
45887
+ `Run its test: npx tsx tests/ws_${base}.test.ts`
45888
+ ];
45889
+ },
45890
+ listener: ({ name }) => {
45891
+ const slug = toSnake(name.replace(/[^0-9a-zA-Z]+/g, "_")).replace(/^_+|_+$/g, "") || "event";
45892
+ return [
45893
+ `Import once in app.ts to register: import "./src/listeners/${slug}.js";`,
45894
+ `Fill the reaction in src/listeners/${slug}.ts`,
45895
+ `Run its test: npx tsx tests/${slug}.test.ts`
45896
+ ];
45897
+ }
45898
+ };
45755
45899
  async function generate2(what, name, extraArgs = []) {
45756
45900
  if (!what) {
45757
45901
  console.error(" Usage: tina4nodejs generate <what> <name> [options]");
@@ -45764,6 +45908,10 @@ async function generate2(what, name, extraArgs = []) {
45764
45908
  process.exit(1);
45765
45909
  }
45766
45910
  const noNameGenerators = /* @__PURE__ */ new Set(["auth"]);
45911
+ if (noNameGenerators.has(what) && name.startsWith("--")) {
45912
+ extraArgs = [name, ...extraArgs];
45913
+ name = "";
45914
+ }
45767
45915
  if (!noNameGenerators.has(what) && !name) {
45768
45916
  console.error(` Usage: tina4nodejs generate ${what} <name> [options]`);
45769
45917
  process.exit(1);
@@ -45780,6 +45928,11 @@ async function generate2(what, name, extraArgs = []) {
45780
45928
  console.error(` Available: ${GENERATOR_LIST}`);
45781
45929
  process.exit(1);
45782
45930
  }
45931
+ const nextFn = NEXT_STEPS[what];
45932
+ if (nextFn) {
45933
+ const resolvedTable = __resolution.body.table_name ?? (name ? SQL_RESERVED_TABLE_NAMES.has(toSnake(name)) ? pluralizeReserved(toSnake(name)) : toSnake(name) : "");
45934
+ setNextSteps(nextFn({ name: name || "", table: resolvedTable }));
45935
+ }
45783
45936
  printResolution();
45784
45937
  }
45785
45938
  function generateModel(name, flags, emitTest = true) {
@@ -45793,7 +45946,8 @@ function generateModel(name, flags, emitTest = true) {
45793
45946
  setResolutionField("file_path", `src/models/${name}.ts`);
45794
45947
  pushTestPath(`tests/${table2}_model.test.ts`);
45795
45948
  const fieldLines = [
45796
- ` id: { type: "integer" as const, primaryKey: true, autoIncrement: true },`
45949
+ ` id: { type: "integer" as const, primaryKey: true, autoIncrement: true },`,
45950
+ ` // tina4:edit add or change fields for this model (string,int,float,bool,text,datetime)`
45797
45951
  ];
45798
45952
  for (const [fname, ftype] of fields) {
45799
45953
  const info = FIELD_TYPE_MAP[ftype] || FIELD_TYPE_MAP.string;
@@ -45848,6 +46002,7 @@ ${modelImportBase}
45848
46002
  export const meta = { summary: "List all ${routePath}", tags: ["${routePath}"] };
45849
46003
 
45850
46004
  export default async function (req: Tina4Request, res: Tina4Response) {
46005
+ // tina4:edit tune pagination defaults or add filter/sort parsing here
45851
46006
  const page = parseInt(req.query.page as string) || 1;
45852
46007
  const limit = parseInt(req.query.limit as string) || 20;
45853
46008
  const offset = (page - 1) * limit;
@@ -45883,6 +46038,7 @@ ${modelImportBase}${secureOptOut(isPublic)}export const meta = { summary: "Creat
45883
46038
 
45884
46039
  // ${writeDoc}
45885
46040
  export default async function (req: Tina4Request, res: Tina4Response) {
46041
+ // tina4:edit validate the body before persist (Validator or hand-checks)
45886
46042
  ${extend(
45887
46043
  "validate / business rules before persist",
45888
46044
  `e.g. reject invalid input; ground: tina4_context("validate before create", "nodejs")`
@@ -45906,6 +46062,7 @@ ${secureOptOut(isPublic)}export const meta = { summary: "Create a new ${singular
45906
46062
 
45907
46063
  // ${writeDoc}
45908
46064
  export default async function (req: Tina4Request, res: Tina4Response) {
46065
+ // tina4:edit fill the create handler (see AI-FILL fill-spec below)
45909
46066
  ${aiFill(`create_${singular}`, {
45910
46067
  intent: `validate the body and persist a new ${singular}`,
45911
46068
  given: "req.body -> the posted fields",
@@ -45968,6 +46125,7 @@ export default async function (req: Tina4Request, res: Tina4Response) {
45968
46125
  res.json({ error: "Not found" }, 404);
45969
46126
  return;
45970
46127
  }
46128
+ // tina4:edit guard which fields may be updated and who may update this row
45971
46129
  ${extend(
45972
46130
  "guard which fields / who may update",
45973
46131
  `e.g. enforce ownership; ground: tina4_context("authorize update", "nodejs")`
@@ -45991,6 +46149,7 @@ ${secureOptOut(isPublic)}export const meta = { summary: "Update a ${singular} by
45991
46149
 
45992
46150
  // ${writeDoc}
45993
46151
  export default async function (req: Tina4Request, res: Tina4Response) {
46152
+ // tina4:edit fill the update handler (see AI-FILL fill-spec below)
45994
46153
  ${aiFill(`update_${singular}`, {
45995
46154
  intent: `load, mutate and save an existing ${singular}`,
45996
46155
  given: "req.params.id -> id; req.body -> changed fields",
@@ -46053,7 +46212,7 @@ function generateCrud(name, flags) {
46053
46212
  const table2 = toTableName(name);
46054
46213
  const routeName = toPlural(table2);
46055
46214
  const isPublic = Boolean(flags.public);
46056
- console.log(`
46215
+ if (!__resolution.jsonMode) console.log(`
46057
46216
  Generating CRUD for ${name}...
46058
46217
  `);
46059
46218
  generateModel(name, flags, false);
@@ -46061,10 +46220,12 @@ function generateCrud(name, flags) {
46061
46220
  generateForm(name, flags);
46062
46221
  generateView(name, flags);
46063
46222
  generateTest(routeName, { model: name, "secure-writes": true, public: isPublic });
46064
- console.log(`
46223
+ if (!__resolution.jsonMode) {
46224
+ console.log(`
46065
46225
  CRUD generation complete for ${name}.`);
46066
- console.log(" Run: tina4nodejs migrate");
46067
- console.log(" Visit: /swagger to see the API docs");
46226
+ console.log(" Run: tina4nodejs migrate");
46227
+ console.log(" Visit: /swagger to see the API docs");
46228
+ }
46068
46229
  }
46069
46230
  function generateMigration(name, flags, fieldsOverride, tableOverride, emitTest = true) {
46070
46231
  const ts = timestamp();
@@ -46153,6 +46314,7 @@ export async function before${name}(
46153
46314
  res: Tina4Response,
46154
46315
  next: () => Promise<void>,
46155
46316
  ): Promise<void> {
46317
+ // tina4:edit replace the Authorization check with the real pre-request rule
46156
46318
  const auth = req.headers["authorization"];
46157
46319
  if (!auth) {
46158
46320
  res.json({ error: "Unauthorized" }, 401);
@@ -46166,7 +46328,7 @@ export async function after${name}(
46166
46328
  res: Tina4Response,
46167
46329
  next: () => Promise<void>,
46168
46330
  ): Promise<void> {
46169
- // Post-processing logic here (logging, header injection, etc.)
46331
+ // tina4:edit add post-processing (logging, header injection, telemetry)
46170
46332
  await next();
46171
46333
  }
46172
46334
  `;
@@ -46242,35 +46404,35 @@ process.exit(fail > 0 ? 1 : 0);
46242
46404
  const list${model}s = tests(
46243
46405
  assertTrue([]),
46244
46406
  )(function list${model}s() {
46245
- // TODO: implement list test
46407
+ // tina4:edit assert against a real GET /api/${toSnake(name)} response (rows, count)
46246
46408
  return true;
46247
46409
  });
46248
46410
 
46249
46411
  const get${model} = tests(
46250
46412
  assertTrue([]),
46251
46413
  )(function get${model}() {
46252
- // TODO: implement get test
46414
+ // tina4:edit assert against GET /api/${toSnake(name)}/{id} for one seeded row
46253
46415
  return true;
46254
46416
  });
46255
46417
 
46256
46418
  const create${model} = tests(
46257
46419
  assertTrue([]),
46258
46420
  )(function create${model}() {
46259
- // TODO: implement create test
46421
+ // tina4:edit POST a valid + an invalid body, assert 201 vs 400
46260
46422
  return true;
46261
46423
  });
46262
46424
 
46263
46425
  const update${model} = tests(
46264
46426
  assertTrue([]),
46265
46427
  )(function update${model}() {
46266
- // TODO: implement update test
46428
+ // tina4:edit PUT changed fields, assert the row was persisted
46267
46429
  return true;
46268
46430
  });
46269
46431
 
46270
46432
  const delete${model} = tests(
46271
46433
  assertTrue([]),
46272
46434
  )(function delete${model}() {
46273
- // TODO: implement delete test
46435
+ // tina4:edit DELETE the id, assert 200 then GET returns 404
46274
46436
  return true;
46275
46437
  });
46276
46438
 
@@ -46287,7 +46449,7 @@ void [list${model}s, get${model}, create${model}, update${model}, delete${model}
46287
46449
  const test${titleName} = tests(
46288
46450
  assertTrue([]),
46289
46451
  )(function test${titleName}() {
46290
- // TODO: implement test
46452
+ // tina4:edit assert against the real behaviour under test (no mocks)
46291
46453
  return true;
46292
46454
  });
46293
46455
 
@@ -46424,7 +46586,7 @@ ${detailFields}
46424
46586
  writeFileSafe(detailPath, detailContent);
46425
46587
  }
46426
46588
  function generateAuth(_flags) {
46427
- console.log("\n Generating authentication scaffolding...\n");
46589
+ if (!__resolution.jsonMode) console.log("\n Generating authentication scaffolding...\n");
46428
46590
  generateModel("User", { fields: "email:string,password:string,role:string" }, false);
46429
46591
  const registerDir = resolve28("src/routes/api/auth/register");
46430
46592
  const loginDir = resolve28("src/routes/api/auth/login");
@@ -46444,6 +46606,7 @@ export const secure = false;
46444
46606
  export const meta = { summary: "Register a new user", tags: ["auth"] };
46445
46607
 
46446
46608
  export default async function (req: Tina4Request, res: Tina4Response) {
46609
+ // tina4:edit add password-strength / email-format / captcha rules before mint
46447
46610
  const { email, password } = (req.body ?? {}) as { email?: string; password?: string };
46448
46611
 
46449
46612
  if (!email || !password) {
@@ -46475,6 +46638,7 @@ export const secure = false;
46475
46638
  export const meta = { summary: "Login and receive JWT token", tags: ["auth"] };
46476
46639
 
46477
46640
  export default async function (req: Tina4Request, res: Tina4Response) {
46641
+ // tina4:edit add rate-limit / lock-after-N-failures / 2FA before password check
46478
46642
  const { email, password } = (req.body ?? {}) as { email?: string; password?: string };
46479
46643
 
46480
46644
  if (!email || !password) {
@@ -46489,6 +46653,7 @@ export default async function (req: Tina4Request, res: Tina4Response) {
46489
46653
  }
46490
46654
 
46491
46655
  const data = user.toObject();
46656
+ // tina4:edit set token TTL (getToken(payload, secret, expiresInMinutes)) and add scopes if needed
46492
46657
  const token = getToken({ userId: data.id, email: data.email, role: data.role });
46493
46658
  res.json({ token });
46494
46659
  }
@@ -46562,11 +46727,13 @@ export default async function (req: Tina4Request, res: Tina4Response) {
46562
46727
  `
46563
46728
  );
46564
46729
  emitAuthTest();
46565
- console.log("\n Authentication scaffolding complete.");
46566
- console.log(" Run: tina4nodejs migrate");
46567
- console.log(" POST /api/auth/register \u2014 create account (public)");
46568
- console.log(" POST /api/auth/login \u2014 get JWT token (public)");
46569
- console.log(" GET /api/auth/me \u2014 get profile (requires token)");
46730
+ if (!__resolution.jsonMode) {
46731
+ console.log("\n Authentication scaffolding complete.");
46732
+ console.log(" Run: tina4nodejs migrate");
46733
+ console.log(" POST /api/auth/register \u2014 create account (public)");
46734
+ console.log(" POST /api/auth/login \u2014 get JWT token (public)");
46735
+ console.log(" GET /api/auth/me \u2014 get profile (requires token)");
46736
+ }
46570
46737
  }
46571
46738
  function generateService(name, flags) {
46572
46739
  const snake = toSnake(name);
@@ -46606,6 +46773,7 @@ function generateService(name, flags) {
46606
46773
  */
46607
46774
 
46608
46775
  export async function ${camel}Task(context: ServiceContext): Promise<void> {
46776
+ // tina4:edit replace the AI-FILL stub below with the scheduled work
46609
46777
  ${body}}
46610
46778
 
46611
46779
  // Discovered by ServiceRunner.discover("src/services") \u2014 it reads name/handler
@@ -46651,6 +46819,7 @@ export function publish${pascal}(payload: Record<string, unknown>): string {
46651
46819
 
46652
46820
  /** Process ONE ${topic} job payload. */
46653
46821
  export async function handle${pascal}(payload: unknown): Promise<void> {
46822
+ // tina4:edit implement the per-job handler; return to ack, throw to nack
46654
46823
  ${body}}
46655
46824
 
46656
46825
  /** Long-running ${topic} worker \u2014 consume() yields jobs; ack/nack each. */
@@ -46701,6 +46870,7 @@ function generateValidator(name, _flags) {
46701
46870
  */
46702
46871
  export function validate${toPascal(name)}(data: Record<string, unknown>): Validator {
46703
46872
  const validator = new Validator(data);
46873
+ // tina4:edit add rules for this payload (.email/.minLength/.integer/.inList/.pattern)
46704
46874
  ${rules} validator.required("name"); // starter rule (matches the model's default field)
46705
46875
  return validator;
46706
46876
  }
@@ -46728,6 +46898,7 @@ import ${name} from "../models/${name}.js";
46728
46898
  * specific shape below. Each callable receives a FakeData instance.
46729
46899
  */
46730
46900
  export function fieldOverrides(fake: FakeData): Record<string, unknown> {
46901
+ // tina4:edit override any fields that need a specific shape (seedOrm auto-fills the rest)
46731
46902
  ${overrides} void fake; // available for overrides above
46732
46903
  return {};
46733
46904
  }
@@ -46785,6 +46956,7 @@ export async function ${handlerName}(
46785
46956
  data: string,
46786
46957
  ): Promise<void> {
46787
46958
  if (event === "open") {
46959
+ // tina4:edit customize the welcome frame (or drop it)
46788
46960
  connection.sendJson({ type: "welcome" });
46789
46961
  return;
46790
46962
  }
@@ -46792,6 +46964,7 @@ export async function ${handlerName}(
46792
46964
  return;
46793
46965
  }
46794
46966
  // event === "message"
46967
+ // tina4:edit handle the inbound "message" frame (broadcast, echo, route, etc.)
46795
46968
  ${body}}
46796
46969
 
46797
46970
  websocket("${wsPath}", ${handlerName});
@@ -46826,6 +46999,7 @@ function generateListener(name, _flags) {
46826
46999
  * Fires when something calls Events.emit("${event}", ...args).
46827
47000
  */
46828
47001
  export function ${handlerName}(...args: unknown[]): void {
47002
+ // tina4:edit implement the reaction to '${event}' (email, ORM write, follow-up emit)
46829
47003
  ${body}}
46830
47004
 
46831
47005
  Events.on("${event}", ${handlerName});
@@ -47261,8 +47435,8 @@ async function runSeeds(seedPath) {
47261
47435
  `);
47262
47436
  let failed = false;
47263
47437
  for (const file of seedFiles) {
47264
- const relative10 = file.replace(cwd + "/", "");
47265
- console.log(` Seeding: ${relative10}`);
47438
+ const relative11 = file.replace(cwd + "/", "");
47439
+ console.log(` Seeding: ${relative11}`);
47266
47440
  try {
47267
47441
  execSync4(`npx tsx "${file}"`, { cwd, stdio: "inherit" });
47268
47442
  } catch {
@@ -47596,10 +47770,14 @@ function buildCommandManifest() {
47596
47770
  framework: "nodejs",
47597
47771
  version: readCliVersion(),
47598
47772
  commands,
47599
- // Feature B (3.13.117): declare the resolution envelope this framework
47600
- // emits for `generate <what> --json`. Consumers read this to know which
47601
- // schema to parse — never hard-code the shape.
47602
- resolution_contract: { version: "1", envelope: RESOLUTION_ENVELOPE_VERSION }
47773
+ // Declare the resolution envelope this framework emits for
47774
+ // `generate <what> --json`. Consumers read this to know which schema to
47775
+ // parse — never hard-code the shape.
47776
+ // 3.13.117: `generate_v1` at version "1"
47777
+ // 3.13.120: `generate_v1_1` at version "1.1" — additive superset
47778
+ // (adds edit_hints[] + next[]; surfaces existing test_paths[]
47779
+ // in the human block). ADR-0063.
47780
+ resolution_contract: { version: "1.1", envelope: RESOLUTION_ENVELOPE_VERSION }
47603
47781
  };
47604
47782
  }
47605
47783
  function runCommands(args = []) {
@@ -138,10 +138,14 @@ export function buildCommandManifest(): CommandManifest {
138
138
  framework: "nodejs",
139
139
  version: readCliVersion(),
140
140
  commands,
141
- // Feature B (3.13.117): declare the resolution envelope this framework
142
- // emits for `generate <what> --json`. Consumers read this to know which
143
- // schema to parse — never hard-code the shape.
144
- resolution_contract: { version: "1", envelope: RESOLUTION_ENVELOPE_VERSION },
141
+ // Declare the resolution envelope this framework emits for
142
+ // `generate <what> --json`. Consumers read this to know which schema to
143
+ // parse — never hard-code the shape.
144
+ // 3.13.117: `generate_v1` at version "1"
145
+ // 3.13.120: `generate_v1_1` at version "1.1" — additive superset
146
+ // (adds edit_hints[] + next[]; surfaces existing test_paths[]
147
+ // in the human block). ADR-0063.
148
+ resolution_contract: { version: "1.1", envelope: RESOLUTION_ENVELOPE_VERSION },
145
149
  };
146
150
  }
147
151
 
@@ -33,7 +33,7 @@
33
33
  * tina4nodejs generate listener user.created
34
34
  */
35
35
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
36
- import { join, resolve } from "node:path";
36
+ 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 }> = {
@@ -62,9 +62,15 @@ function ensureDir(dir: string): void {
62
62
  }
63
63
 
64
64
  function writeFileSafe(path: string, content: string): void {
65
+ // ALWAYS scan the intended content for `// tina4:edit` markers, regardless
66
+ // of dry-run. The envelope's edit_hints[] MUST promise the same hints in
67
+ // preview (--dry-run) and post-write so an agent can rely on them before
68
+ // committing to disk.
69
+ captureEditHints(path, content);
70
+
65
71
  if (__resolution.dryRun) {
66
- // Dry-run: record what WOULD have been written, but touch no disk state
67
- // and print no per-file line to stdout (that would leak into --json).
72
+ // Dry-run: touch no disk state and print no per-file line to stdout
73
+ // (that would leak into --json).
68
74
  return;
69
75
  }
70
76
  if (existsSync(path)) {
@@ -152,6 +158,18 @@ export interface ResolutionInput {
152
158
  fields: string | null;
153
159
  }
154
160
 
161
+ /**
162
+ * One `// tina4:edit …` marker found in a written (or would-be-written)
163
+ * template file. `file` is repo-relative POSIX (matches the rest of the
164
+ * envelope's paths); `line` is 1-based; `label` is the short imperative label
165
+ * that followed the marker on the same line.
166
+ */
167
+ export interface EditHint {
168
+ file: string;
169
+ line: number;
170
+ label: string;
171
+ }
172
+
155
173
  export interface ResolutionBody {
156
174
  class_name?: string;
157
175
  table_name?: string;
@@ -159,6 +177,8 @@ export interface ResolutionBody {
159
177
  migration_path?: string;
160
178
  routes?: string[];
161
179
  test_paths?: string[];
180
+ edit_hints?: EditHint[];
181
+ next?: string[];
162
182
  transformations: ResolutionTransformation[];
163
183
  }
164
184
 
@@ -176,8 +196,15 @@ export interface ResolutionEnvelope {
176
196
  * `resolution_contract.envelope` so the tina4 client (or any consumer) can
177
197
  * discover the exact contract this framework speaks. Bump when a breaking
178
198
  * key rename / removal lands; keep unchanged when new OPTIONAL keys are added.
199
+ *
200
+ * `generate_v1_1` (ADR-0063, 3.13.120) is a PURELY ADDITIVE superset of
201
+ * `generate_v1`: every v1 field is preserved, and two new optional arrays
202
+ * appear — `resolution.edit_hints[]` (one entry per `// tina4:edit` marker
203
+ * baked into a template) and `resolution.next[]` (curated per-verb actionable
204
+ * next steps). `resolution.test_paths[]` was already in v1; v1.1 surfaces it
205
+ * in the human stderr block too.
179
206
  */
180
- export const RESOLUTION_ENVELOPE_VERSION = "generate_v1";
207
+ export const RESOLUTION_ENVELOPE_VERSION = "generate_v1_1";
181
208
 
182
209
  /**
183
210
  * Per-run mutable resolution state. Reset by `resetResolution()` on every
@@ -216,14 +243,27 @@ function recordTransformation(t: ResolutionTransformation): void {
216
243
  /** Read-only snapshot of the current resolution — exported for tests that
217
244
  * want to inspect it in-process (the CLI itself uses only the envelope). */
218
245
  export function currentResolution(): ResolutionEnvelope {
246
+ const body: ResolutionBody = {
247
+ ...__resolution.body,
248
+ transformations: [...__resolution.body.transformations],
249
+ };
250
+ if (__resolution.body.edit_hints) {
251
+ body.edit_hints = __resolution.body.edit_hints.map((h) => ({ ...h }));
252
+ }
253
+ if (__resolution.body.next) {
254
+ body.next = [...__resolution.body.next];
255
+ }
256
+ if (__resolution.body.test_paths) {
257
+ body.test_paths = [...__resolution.body.test_paths];
258
+ }
259
+ if (__resolution.body.routes) {
260
+ body.routes = [...__resolution.body.routes];
261
+ }
219
262
  return {
220
263
  command: "generate",
221
264
  target: __resolution.target,
222
265
  input: { ...__resolution.input },
223
- resolution: {
224
- ...__resolution.body,
225
- transformations: [...__resolution.body.transformations],
226
- },
266
+ resolution: body,
227
267
  actions_taken: [...__resolution.actionsTaken],
228
268
  dry_run: __resolution.dryRun,
229
269
  };
@@ -243,6 +283,57 @@ function pushTestPath(path: string): void {
243
283
  __resolution.body.test_paths.push(path);
244
284
  }
245
285
 
286
+ function pushEditHint(hint: EditHint): void {
287
+ if (!__resolution.body.edit_hints) __resolution.body.edit_hints = [];
288
+ __resolution.body.edit_hints.push(hint);
289
+ }
290
+
291
+ function setNextSteps(steps: string[]): void {
292
+ if (steps.length === 0) return;
293
+ __resolution.body.next = [...steps];
294
+ }
295
+
296
+ /**
297
+ * Convert an absolute path to a repo-relative POSIX path. Every other
298
+ * envelope path (file_path, migration_path, test_paths) is repo-relative
299
+ * POSIX ("src/models/Order.ts"), so edit_hints follow the same convention —
300
+ * one path style across the whole envelope, portable across Windows.
301
+ */
302
+ function toRelPath(absPath: string): string {
303
+ const cwd = process.cwd();
304
+ const rel = relative(cwd, absPath);
305
+ if (!rel) return absPath;
306
+ return sep === "/" ? rel : rel.split(sep).join("/");
307
+ }
308
+
309
+ // Line-anchored `// tina4:edit LABEL` marker regex.
310
+ //
311
+ // Line-anchored (`^\s*//`) so the marker must be at the START of a code line
312
+ // (after optional whitespace) — a marker embedded inside a template string
313
+ // literal never falsely matches. LABEL is captured greedily until end of
314
+ // line, then trimmed on push.
315
+ const TINA4_EDIT_MARKER = /^\s*\/\/\s*tina4:edit\s+(.+?)\s*$/;
316
+
317
+ /**
318
+ * Scan `content` for `// tina4:edit …` markers and record one EditHint per
319
+ * match against the given absolute path. Called from every `writeFileSafe`
320
+ * — including under `--dry-run` — so the envelope promises the same hints in
321
+ * preview and post-write. Files whose extension is not TS/JS are skipped
322
+ * (the marker syntax is TS/JS specific — `.sql` and `.twig` templates never
323
+ * carry markers).
324
+ */
325
+ function captureEditHints(absPath: string, content: string): void {
326
+ if (!/\.(ts|tsx|js|mjs|cjs|jsx)$/.test(absPath)) return;
327
+ const relPath = toRelPath(absPath);
328
+ const lines = content.split("\n");
329
+ for (let i = 0; i < lines.length; i++) {
330
+ const match = TINA4_EDIT_MARKER.exec(lines[i]);
331
+ if (match) {
332
+ pushEditHint({ file: relPath, line: i + 1, label: match[1].trim() });
333
+ }
334
+ }
335
+ }
336
+
246
337
  /**
247
338
  * Emit the resolution — as JSON on STDOUT for `--json`, otherwise as a human
248
339
  * block on STDERR (stderr so a caller piping stdout for other output isn't
@@ -282,6 +373,28 @@ function printResolution(): void {
282
373
  lines.push(` To keep the raw name '${reserved.from}' as the table:`);
283
374
  lines.push(` tina4nodejs generate ${__resolution.target} ${__resolution.input.name} ${reserved.override}`);
284
375
  }
376
+ // v1.1 (ADR-0063): surface the already-populated test_paths[], and the two
377
+ // new arrays (edit_hints, next) when either is non-empty. Sections stay
378
+ // absent when the corresponding array is empty — a listener/service
379
+ // scaffold prints exactly what a model scaffold prints, minus what does
380
+ // not apply.
381
+ if (b.test_paths && b.test_paths.length > 0) {
382
+ lines.push("");
383
+ lines.push(" Tests:");
384
+ for (const testPath of b.test_paths) lines.push(` ${testPath}`);
385
+ }
386
+ if (b.edit_hints && b.edit_hints.length > 0) {
387
+ lines.push("");
388
+ lines.push(" Edit these lines:");
389
+ for (const hint of b.edit_hints) {
390
+ lines.push(` ${hint.file}:${hint.line} ${hint.label}`);
391
+ }
392
+ }
393
+ if (b.next && b.next.length > 0) {
394
+ lines.push("");
395
+ lines.push(" Next:");
396
+ for (const step of b.next) lines.push(` ${step}`);
397
+ }
285
398
  lines.push("");
286
399
  process.stderr.write(lines.join("\n"));
287
400
  }
@@ -477,6 +590,105 @@ export const GENERATORS: Record<string, GeneratorSpec> = {
477
590
  /** Comma-separated generator names for usage/error output — derived, never a hand-kept list. */
478
591
  const GENERATOR_LIST = Object.keys(GENERATORS).join(", ");
479
592
 
593
+ /**
594
+ * Curated per-verb next steps — populates `resolution.next[]` (envelope) and
595
+ * the "Next:" block on stderr (human). Grounded on the real code paths a
596
+ * developer takes after each generator. Cap of 5 (short, actionable).
597
+ *
598
+ * The context carries the RESOLVED name and table (the reserved-word
599
+ * pluraliser has already run at dispatch time), so a step references the
600
+ * same table/route the generated files bind to. `name` is the CLI positional
601
+ * as-typed; `table` is `toTableName(name)`.
602
+ */
603
+ interface NextContext { name: string; table: string; }
604
+ const NEXT_STEPS: Record<string, (c: NextContext) => string[]> = {
605
+ model: ({ name, table }) => [
606
+ `Edit src/models/${name}.ts to add fields beyond the default 'name'`,
607
+ `Apply the migration: npx tina4nodejs migrate`,
608
+ `Run its test: npx tsx tests/${table}_model.test.ts`,
609
+ `Add CRUD scaffolding: npx tina4nodejs generate crud ${name}`,
610
+ ],
611
+ route: ({ name, table }) => [
612
+ `Fill the AI-FILL stubs in src/routes/api/${name.replace(/^\//, "")}/`,
613
+ `Run its test: npx tsx tests/${table}.test.ts`,
614
+ `Serve and try: npx tina4nodejs serve -> curl http://localhost:7148/api/${name.replace(/^\//, "")}`,
615
+ ],
616
+ crud: ({ name, table }) => [
617
+ `Apply the migration: npx tina4nodejs migrate`,
618
+ `Serve and try: npx tina4nodejs serve -> visit /swagger`,
619
+ `Run the gate test: npx tsx tests/${toPlural(table)}.test.ts`,
620
+ `Change fields: edit src/models/${name}.ts then re-run generate crud`,
621
+ ],
622
+ migration: () => [
623
+ `Apply pending migrations: npx tina4nodejs migrate`,
624
+ `Check status: npx tina4nodejs migrate:status`,
625
+ `Roll back the batch: npx tina4nodejs migrate:rollback`,
626
+ ],
627
+ middleware: ({ name }) => [
628
+ `Wire it: router.middleware(before${name}, after${name}) — or bind per-route`,
629
+ `Run its test: npx tsx tests/${toSnake(name)}.test.ts`,
630
+ ],
631
+ test: ({ name }) => [
632
+ `Fill the TODOs in tests/${toSnake(name)}.test.ts`,
633
+ `Run it: npx tsx tests/${toSnake(name)}.test.ts`,
634
+ ],
635
+ form: ({ name, table }) => [
636
+ `Render from a route: res.render("forms/${table}.twig", { item })`,
637
+ `Add the POST route: npx tina4nodejs generate route ${toPlural(table)} --model ${name}`,
638
+ ],
639
+ view: ({ table }) => [
640
+ `Wire routes to render list -> ${toPlural(table)}.twig, detail -> ${table}.twig`,
641
+ `Customize the templates in src/templates/pages/`,
642
+ ],
643
+ auth: () => [
644
+ `Apply the migration: npx tina4nodejs migrate`,
645
+ `Run the auth test: npx tsx tests/auth.test.ts`,
646
+ `Try register: curl -X POST http://localhost:7148/api/auth/register -d '{"email":"a@b.c","password":"secret12"}' -H 'content-type: application/json'`,
647
+ `Login: curl -X POST http://localhost:7148/api/auth/login -d '{"email":"a@b.c","password":"secret12"}' -H 'content-type: application/json'`,
648
+ ],
649
+ service: ({ name }) => [
650
+ `Wire ServiceRunner in app.ts: await ServiceRunner.discover("src/services"); ServiceRunner.start();`,
651
+ `Fill the task body in src/services/${toSnake(name)}.ts`,
652
+ `Run its test: npx tsx tests/${toSnake(name)}.test.ts`,
653
+ ],
654
+ queue: ({ name }) => {
655
+ const slug = toSnake(name.replace(/[^0-9a-zA-Z]+/g, "_")).replace(/^_+|_+$/g, "") || "topic";
656
+ return [
657
+ `Fill handle${toPascal(name)}() in src/services/${slug}_consumer.ts`,
658
+ `Produce a job: publish${toPascal(name)}({ ... })`,
659
+ `Run the worker: npx tina4nodejs queue work ${name}`,
660
+ `Run its test: npx tsx tests/${slug}.test.ts`,
661
+ ];
662
+ },
663
+ validator: ({ name }) => [
664
+ `Add rules in src/validators/${toSnake(name)}.ts (.email/.minLength/.integer/.inList/.pattern)`,
665
+ `Run its test: npx tsx tests/${toSnake(name)}.test.ts`,
666
+ ],
667
+ seeder: ({ name, table }) => [
668
+ `Override any fields that need a specific shape in src/seeds/${table}_seeder.ts`,
669
+ `Seed the table: npx tina4nodejs seed`,
670
+ `Run its test: npx tsx tests/${table}_seeder.test.ts`,
671
+ ],
672
+ websocket: ({ name }) => {
673
+ const raw = name.trim();
674
+ const slugRaw = toSnake(raw.replace(/^\/+|\/+$/g, "").replace(/[^0-9a-zA-Z]+/g, "_")).replace(/^_+|_+$/g, "") || "ws";
675
+ const base = slugRaw.startsWith("ws_") ? slugRaw.slice(3) : slugRaw;
676
+ return [
677
+ `Import once in app.ts to register: import "./src/routes/ws_${base}.js";`,
678
+ `Fill the "message" branch in src/routes/ws_${base}.ts`,
679
+ `Run its test: npx tsx tests/ws_${base}.test.ts`,
680
+ ];
681
+ },
682
+ listener: ({ name }) => {
683
+ const slug = toSnake(name.replace(/[^0-9a-zA-Z]+/g, "_")).replace(/^_+|_+$/g, "") || "event";
684
+ return [
685
+ `Import once in app.ts to register: import "./src/listeners/${slug}.js";`,
686
+ `Fill the reaction in src/listeners/${slug}.ts`,
687
+ `Run its test: npx tsx tests/${slug}.test.ts`,
688
+ ];
689
+ },
690
+ };
691
+
480
692
  // ── Main entry point ────────────────────────────────────────────────
481
693
 
482
694
  export async function generate(what: string, name: string, extraArgs: string[] = []): Promise<void> {
@@ -491,8 +703,17 @@ export async function generate(what: string, name: string, extraArgs: string[] =
491
703
  process.exit(1);
492
704
  }
493
705
 
494
- // Auth doesn't require a name
706
+ // Auth doesn't require a name.
495
707
  const noNameGenerators = new Set(["auth"]);
708
+ // bin.ts always passes argv[1] as `name`. For a no-name generator that means
709
+ // `generate auth --json` arrives here as name="--json", extraArgs=[]. Rescue
710
+ // the flag: shift a leading `--foo` name into extraArgs so parseCliArgs
711
+ // actually sees it. Fixes `--json` (and any other flag) being silently
712
+ // eaten by the no-name verbs.
713
+ if (noNameGenerators.has(what) && name.startsWith("--")) {
714
+ extraArgs = [name, ...extraArgs];
715
+ name = "";
716
+ }
496
717
  if (!noNameGenerators.has(what) && !name) {
497
718
  console.error(` Usage: tina4nodejs generate ${what} <name> [options]`);
498
719
  process.exit(1);
@@ -520,6 +741,21 @@ export async function generate(what: string, name: string, extraArgs: string[] =
520
741
  process.exit(1);
521
742
  }
522
743
 
744
+ // v1.1 (ADR-0063): populate `resolution.next[]` from the per-verb curator
745
+ // AFTER dispatch, so the table name reflects any reserved-word pluralisation
746
+ // that fired during the run (Order -> orders). Prefer the resolution's own
747
+ // table_name (already set by generateModel/generateMigration) so we do NOT
748
+ // re-invoke toTableName() — that would record a DUPLICATE
749
+ // reserved_word_pluralize transformation on the envelope.
750
+ const nextFn = NEXT_STEPS[what];
751
+ if (nextFn) {
752
+ const resolvedTable = __resolution.body.table_name
753
+ ?? (name
754
+ ? (SQL_RESERVED_TABLE_NAMES.has(toSnake(name)) ? pluralizeReserved(toSnake(name)) : toSnake(name))
755
+ : "");
756
+ setNextSteps(nextFn({ name: name || "", table: resolvedTable }));
757
+ }
758
+
523
759
  // Emit the resolution AFTER dispatch so `actions_taken` reflects the real
524
760
  // writes (or the empty list under `--dry-run`).
525
761
  printResolution();
@@ -549,6 +785,7 @@ function generateModel(name: string, flags: Record<string, string | boolean>, em
549
785
  // Build field definitions
550
786
  const fieldLines: string[] = [
551
787
  ` id: { type: "integer" as const, primaryKey: true, autoIncrement: true },`,
788
+ ` // tina4:edit add or change fields for this model (string,int,float,bool,text,datetime)`,
552
789
  ];
553
790
  for (const [fname, ftype] of fields) {
554
791
  const info = FIELD_TYPE_MAP[ftype] || FIELD_TYPE_MAP.string;
@@ -637,6 +874,7 @@ ${modelImportBase}
637
874
  export const meta = { summary: "List all ${routePath}", tags: ["${routePath}"] };
638
875
 
639
876
  export default async function (req: Tina4Request, res: Tina4Response) {
877
+ // tina4:edit tune pagination defaults or add filter/sort parsing here
640
878
  const page = parseInt(req.query.page as string) || 1;
641
879
  const limit = parseInt(req.query.limit as string) || 20;
642
880
  const offset = (page - 1) * limit;
@@ -674,6 +912,7 @@ ${modelImportBase}${secureOptOut(isPublic)}export const meta = { summary: "Creat
674
912
 
675
913
  // ${writeDoc}
676
914
  export default async function (req: Tina4Request, res: Tina4Response) {
915
+ // tina4:edit validate the body before persist (Validator or hand-checks)
677
916
  ${extend("validate / business rules before persist",
678
917
  `e.g. reject invalid input; ground: tina4_context("validate before create", "nodejs")`)} const item = new ${model}(req.body as Record<string, unknown>);
679
918
  // save() returns false on failure rather than throwing - check it, or a failed
@@ -695,6 +934,7 @@ ${secureOptOut(isPublic)}export const meta = { summary: "Create a new ${singular
695
934
 
696
935
  // ${writeDoc}
697
936
  export default async function (req: Tina4Request, res: Tina4Response) {
937
+ // tina4:edit fill the create handler (see AI-FILL fill-spec below)
698
938
  ${aiFill(`create_${singular}`, {
699
939
  intent: `validate the body and persist a new ${singular}`,
700
940
  given: "req.body -> the posted fields",
@@ -761,6 +1001,7 @@ export default async function (req: Tina4Request, res: Tina4Response) {
761
1001
  res.json({ error: "Not found" }, 404);
762
1002
  return;
763
1003
  }
1004
+ // tina4:edit guard which fields may be updated and who may update this row
764
1005
  ${extend("guard which fields / who may update",
765
1006
  `e.g. enforce ownership; ground: tina4_context("authorize update", "nodejs")`)} Object.assign(item, req.body as Record<string, unknown>);
766
1007
  // save() returns false on failure rather than throwing - check it, or a failed
@@ -782,6 +1023,7 @@ ${secureOptOut(isPublic)}export const meta = { summary: "Update a ${singular} by
782
1023
 
783
1024
  // ${writeDoc}
784
1025
  export default async function (req: Tina4Request, res: Tina4Response) {
1026
+ // tina4:edit fill the update handler (see AI-FILL fill-spec below)
785
1027
  ${aiFill(`update_${singular}`, {
786
1028
  intent: `load, mutate and save an existing ${singular}`,
787
1029
  given: "req.params.id -> id; req.body -> changed fields",
@@ -855,7 +1097,10 @@ function generateCrud(name: string, flags: Record<string, string | boolean>): vo
855
1097
  const routeName = toPlural(table);
856
1098
  const isPublic = Boolean(flags.public);
857
1099
 
858
- console.log(`\n Generating CRUD for ${name}...\n`);
1100
+ // Human-only banners; suppressed under --json to keep stdout parseable
1101
+ // (a console.log during --json produced invalid JSON). writeFileSafe already
1102
+ // gates its own "Created …" lines the same way.
1103
+ if (!__resolution.jsonMode) console.log(`\n Generating CRUD for ${name}...\n`);
859
1104
 
860
1105
  // 1. Model + migration (its own model test is suppressed — the gate test
861
1106
  // below is CRUD's single, broader co-emitted test).
@@ -875,9 +1120,11 @@ function generateCrud(name: string, flags: Record<string, string | boolean>): vo
875
1120
  // 5. Test — real secure-by-default boot-gate (reads public, writes gated).
876
1121
  generateTest(routeName, { model: name, "secure-writes": true, public: isPublic });
877
1122
 
878
- console.log(`\n CRUD generation complete for ${name}.`);
879
- console.log(" Run: tina4nodejs migrate");
880
- console.log(" Visit: /swagger to see the API docs");
1123
+ if (!__resolution.jsonMode) {
1124
+ console.log(`\n CRUD generation complete for ${name}.`);
1125
+ console.log(" Run: tina4nodejs migrate");
1126
+ console.log(" Visit: /swagger to see the API docs");
1127
+ }
881
1128
  }
882
1129
 
883
1130
  // ── Migration ───────────────────────────────────────────────────────
@@ -1008,6 +1255,7 @@ export async function before${name}(
1008
1255
  res: Tina4Response,
1009
1256
  next: () => Promise<void>,
1010
1257
  ): Promise<void> {
1258
+ // tina4:edit replace the Authorization check with the real pre-request rule
1011
1259
  const auth = req.headers["authorization"];
1012
1260
  if (!auth) {
1013
1261
  res.json({ error: "Unauthorized" }, 401);
@@ -1021,7 +1269,7 @@ export async function after${name}(
1021
1269
  res: Tina4Response,
1022
1270
  next: () => Promise<void>,
1023
1271
  ): Promise<void> {
1024
- // Post-processing logic here (logging, header injection, etc.)
1272
+ // tina4:edit add post-processing (logging, header injection, telemetry)
1025
1273
  await next();
1026
1274
  }
1027
1275
  `;
@@ -1113,35 +1361,35 @@ process.exit(fail > 0 ? 1 : 0);
1113
1361
  const list${model}s = tests(
1114
1362
  assertTrue([]),
1115
1363
  )(function list${model}s() {
1116
- // TODO: implement list test
1364
+ // tina4:edit assert against a real GET /api/${toSnake(name)} response (rows, count)
1117
1365
  return true;
1118
1366
  });
1119
1367
 
1120
1368
  const get${model} = tests(
1121
1369
  assertTrue([]),
1122
1370
  )(function get${model}() {
1123
- // TODO: implement get test
1371
+ // tina4:edit assert against GET /api/${toSnake(name)}/{id} for one seeded row
1124
1372
  return true;
1125
1373
  });
1126
1374
 
1127
1375
  const create${model} = tests(
1128
1376
  assertTrue([]),
1129
1377
  )(function create${model}() {
1130
- // TODO: implement create test
1378
+ // tina4:edit POST a valid + an invalid body, assert 201 vs 400
1131
1379
  return true;
1132
1380
  });
1133
1381
 
1134
1382
  const update${model} = tests(
1135
1383
  assertTrue([]),
1136
1384
  )(function update${model}() {
1137
- // TODO: implement update test
1385
+ // tina4:edit PUT changed fields, assert the row was persisted
1138
1386
  return true;
1139
1387
  });
1140
1388
 
1141
1389
  const delete${model} = tests(
1142
1390
  assertTrue([]),
1143
1391
  )(function delete${model}() {
1144
- // TODO: implement delete test
1392
+ // tina4:edit DELETE the id, assert 200 then GET returns 404
1145
1393
  return true;
1146
1394
  });
1147
1395
 
@@ -1158,7 +1406,7 @@ void [list${model}s, get${model}, create${model}, update${model}, delete${model}
1158
1406
  const test${titleName} = tests(
1159
1407
  assertTrue([]),
1160
1408
  )(function test${titleName}() {
1161
- // TODO: implement test
1409
+ // tina4:edit assert against the real behaviour under test (no mocks)
1162
1410
  return true;
1163
1411
  });
1164
1412
 
@@ -1322,7 +1570,8 @@ function generateView(name: string, flags: Record<string, string | boolean>): vo
1322
1570
  // ── Auth (login/register stay PUBLIC) ───────────────────────────────
1323
1571
 
1324
1572
  function generateAuth(_flags: Record<string, string | boolean>): void {
1325
- console.log("\n Generating authentication scaffolding...\n");
1573
+ // Human-only banners; suppressed under --json to keep stdout parseable.
1574
+ if (!__resolution.jsonMode) console.log("\n Generating authentication scaffolding...\n");
1326
1575
 
1327
1576
  // 1. User model + migration (model test suppressed — the auth test below is
1328
1577
  // the composite, broader co-emitted test).
@@ -1352,6 +1601,7 @@ export const secure = false;
1352
1601
  export const meta = { summary: "Register a new user", tags: ["auth"] };
1353
1602
 
1354
1603
  export default async function (req: Tina4Request, res: Tina4Response) {
1604
+ // tina4:edit add password-strength / email-format / captcha rules before mint
1355
1605
  const { email, password } = (req.body ?? {}) as { email?: string; password?: string };
1356
1606
 
1357
1607
  if (!email || !password) {
@@ -1385,6 +1635,7 @@ export const secure = false;
1385
1635
  export const meta = { summary: "Login and receive JWT token", tags: ["auth"] };
1386
1636
 
1387
1637
  export default async function (req: Tina4Request, res: Tina4Response) {
1638
+ // tina4:edit add rate-limit / lock-after-N-failures / 2FA before password check
1388
1639
  const { email, password } = (req.body ?? {}) as { email?: string; password?: string };
1389
1640
 
1390
1641
  if (!email || !password) {
@@ -1399,6 +1650,7 @@ export default async function (req: Tina4Request, res: Tina4Response) {
1399
1650
  }
1400
1651
 
1401
1652
  const data = user.toObject();
1653
+ // tina4:edit set token TTL (getToken(payload, secret, expiresInMinutes)) and add scopes if needed
1402
1654
  const token = getToken({ userId: data.id, email: data.email, role: data.role });
1403
1655
  res.json({ token });
1404
1656
  }
@@ -1482,11 +1734,13 @@ export default async function (req: Tina4Request, res: Tina4Response) {
1482
1734
  // 5. Auth test — real register / login / me end-to-end (no mocks).
1483
1735
  emitAuthTest();
1484
1736
 
1485
- console.log("\n Authentication scaffolding complete.");
1486
- console.log(" Run: tina4nodejs migrate");
1487
- console.log(" POST /api/auth/register — create account (public)");
1488
- console.log(" POST /api/auth/login get JWT token (public)");
1489
- console.log(" GET /api/auth/me — get profile (requires token)");
1737
+ if (!__resolution.jsonMode) {
1738
+ console.log("\n Authentication scaffolding complete.");
1739
+ console.log(" Run: tina4nodejs migrate");
1740
+ console.log(" POST /api/auth/register create account (public)");
1741
+ console.log(" POST /api/auth/login — get JWT token (public)");
1742
+ console.log(" GET /api/auth/me — get profile (requires token)");
1743
+ }
1490
1744
  }
1491
1745
 
1492
1746
  // ── Service (scheduled background task — ServiceRunner) ──────────────
@@ -1537,6 +1791,7 @@ function generateService(name: string, flags: Record<string, string | boolean>):
1537
1791
  */
1538
1792
 
1539
1793
  export async function ${camel}Task(context: ServiceContext): Promise<void> {
1794
+ // tina4:edit replace the AI-FILL stub below with the scheduled work
1540
1795
  ${body}}
1541
1796
 
1542
1797
  // Discovered by ServiceRunner.discover("src/services") — it reads name/handler
@@ -1593,6 +1848,7 @@ export function publish${pascal}(payload: Record<string, unknown>): string {
1593
1848
 
1594
1849
  /** Process ONE ${topic} job payload. */
1595
1850
  export async function handle${pascal}(payload: unknown): Promise<void> {
1851
+ // tina4:edit implement the per-job handler; return to ack, throw to nack
1596
1852
  ${body}}
1597
1853
 
1598
1854
  /** Long-running ${topic} worker — consume() yields jobs; ack/nack each. */
@@ -1655,6 +1911,7 @@ function generateValidator(name: string, _flags: Record<string, string | boolean
1655
1911
  */
1656
1912
  export function validate${toPascal(name)}(data: Record<string, unknown>): Validator {
1657
1913
  const validator = new Validator(data);
1914
+ // tina4:edit add rules for this payload (.email/.minLength/.integer/.inList/.pattern)
1658
1915
  ${rules} validator.required("name"); // starter rule (matches the model's default field)
1659
1916
  return validator;
1660
1917
  }
@@ -1695,6 +1952,7 @@ import ${name} from "../models/${name}.js";
1695
1952
  * specific shape below. Each callable receives a FakeData instance.
1696
1953
  */
1697
1954
  export function fieldOverrides(fake: FakeData): Record<string, unknown> {
1955
+ // tina4:edit override any fields that need a specific shape (seedOrm auto-fills the rest)
1698
1956
  ${overrides} void fake; // available for overrides above
1699
1957
  return {};
1700
1958
  }
@@ -1763,6 +2021,7 @@ export async function ${handlerName}(
1763
2021
  data: string,
1764
2022
  ): Promise<void> {
1765
2023
  if (event === "open") {
2024
+ // tina4:edit customize the welcome frame (or drop it)
1766
2025
  connection.sendJson({ type: "welcome" });
1767
2026
  return;
1768
2027
  }
@@ -1770,6 +2029,7 @@ export async function ${handlerName}(
1770
2029
  return;
1771
2030
  }
1772
2031
  // event === "message"
2032
+ // tina4:edit handle the inbound "message" frame (broadcast, echo, route, etc.)
1773
2033
  ${body}}
1774
2034
 
1775
2035
  websocket("${wsPath}", ${handlerName});
@@ -1814,6 +2074,7 @@ function generateListener(name: string, _flags: Record<string, string | boolean>
1814
2074
  * Fires when something calls Events.emit("${event}", ...args).
1815
2075
  */
1816
2076
  export function ${handlerName}(...args: unknown[]): void {
2077
+ // tina4:edit implement the reaction to '${event}' (email, ORM write, follow-up emit)
1817
2078
  ${body}}
1818
2079
 
1819
2080
  Events.on("${event}", ${handlerName});
@@ -23,6 +23,17 @@ export interface ResolutionInput {
23
23
  name: string;
24
24
  fields: string | null;
25
25
  }
26
+ /**
27
+ * One `// tina4:edit …` marker found in a written (or would-be-written)
28
+ * template file. `file` is repo-relative POSIX (matches the rest of the
29
+ * envelope's paths); `line` is 1-based; `label` is the short imperative label
30
+ * that followed the marker on the same line.
31
+ */
32
+ export interface EditHint {
33
+ file: string;
34
+ line: number;
35
+ label: string;
36
+ }
26
37
  export interface ResolutionBody {
27
38
  class_name?: string;
28
39
  table_name?: string;
@@ -30,6 +41,8 @@ export interface ResolutionBody {
30
41
  migration_path?: string;
31
42
  routes?: string[];
32
43
  test_paths?: string[];
44
+ edit_hints?: EditHint[];
45
+ next?: string[];
33
46
  transformations: ResolutionTransformation[];
34
47
  }
35
48
  export interface ResolutionEnvelope {
@@ -45,8 +58,15 @@ export interface ResolutionEnvelope {
45
58
  * `resolution_contract.envelope` so the tina4 client (or any consumer) can
46
59
  * discover the exact contract this framework speaks. Bump when a breaking
47
60
  * key rename / removal lands; keep unchanged when new OPTIONAL keys are added.
61
+ *
62
+ * `generate_v1_1` (ADR-0063, 3.13.120) is a PURELY ADDITIVE superset of
63
+ * `generate_v1`: every v1 field is preserved, and two new optional arrays
64
+ * appear — `resolution.edit_hints[]` (one entry per `// tina4:edit` marker
65
+ * baked into a template) and `resolution.next[]` (curated per-verb actionable
66
+ * next steps). `resolution.test_paths[]` was already in v1; v1.1 surfaces it
67
+ * in the human stderr block too.
48
68
  */
49
- export declare const RESOLUTION_ENVELOPE_VERSION = "generate_v1";
69
+ export declare const RESOLUTION_ENVELOPE_VERSION = "generate_v1_1";
50
70
  /** Read-only snapshot of the current resolution — exported for tests that
51
71
  * want to inspect it in-process (the CLI itself uses only the envelope). */
52
72
  export declare function currentResolution(): ResolutionEnvelope;