@nanobpm/urban 0.77.0 → 0.77.2

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.
@@ -0,0 +1,369 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { spawnSync } from "node:child_process";
4
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, cpSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join, dirname } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { runGen, type GenIO } from "../gen.ts";
9
+ import {
10
+ emitDomainBindings,
11
+ emitDomainDts,
12
+ emitDomainModel,
13
+ type SourceSchema,
14
+ } from "./domain.ts";
15
+ import { emitMeta } from "./meta.ts";
16
+ import { emitMessageBindings, emitMessageBindingsRuntime } from "./messages.ts";
17
+ import { emitWorkerBindings, emitWorkerBindingsRuntime } from "./worker-io.ts";
18
+ import { emitApiBindings, emitApiController } from "./api.ts";
19
+ import type { OpenApiDoc } from "../../openapi/spec.ts";
20
+
21
+ // These guards spawn the scaffold's Biome *via Node* (`process.execPath <biome> check`). Under
22
+ // `npm run test:deno` `process.execPath` is the Deno binary, so that spawn would launch Deno rather
23
+ // than Biome and the lint command would fail spuriously. The generated output is runtime-independent
24
+ // (it's plain text the emitters produce identically under either runtime), so running this guard once
25
+ // under Node is sufficient — skip it under Deno instead of mis-spawning.
26
+ const runtimeIsNode = !("Deno" in globalThis);
27
+ const nodeOnly = {
28
+ skip: runtimeIsNode ? false : "Node-only: spawns Biome via process.execPath (Deno's execPath is not Node)",
29
+ };
30
+
31
+ // Defect-class guard (AGENTS.md "write a test guard for the defect class"): the emitters must
32
+ // produce `nano-generated/` code that passes the `create-urban-app` scaffold's own Biome lint
33
+ // ruleset — the exact standard a scaffolded app enforces via `npm run lint`. This locks the whole
34
+ // generator surface (types, meta accessor, domain/message/worker bindings, and the API controller's
35
+ // import order) to lint-clean output, so a future emitter change that reintroduces a banned type,
36
+ // an `as` cast, or a mis-sorted import fails here instead of only surfacing in a user's project.
37
+ //
38
+ // The generated code is deliberately console/deno-styled (2-space, quoted keys) for byte-parity
39
+ // with the console emitters (ADR 0053), so — mirroring the scaffold's `biome.json` — the formatter
40
+ // is scoped away from `nano-generated/` while the linter (and import-organizing assist) still apply.
41
+
42
+ /** In-memory filesystem so gen runs without touching disk. */
43
+ function memIO(files: Record<string, string>): GenIO & { files: Record<string, string> } {
44
+ return {
45
+ files,
46
+ async readText(p) {
47
+ if (!(p in files)) throw new Error(`ENOENT ${p}`);
48
+ return files[p];
49
+ },
50
+ async writeText(p, c) {
51
+ files[p] = c;
52
+ },
53
+ async listDir(p) {
54
+ const prefix = p.replace(/\/+$/, "") + "/";
55
+ const names = new Set<string>();
56
+ for (const f of Object.keys(files)) {
57
+ if (f.startsWith(prefix)) {
58
+ const rest = f.slice(prefix.length);
59
+ if (!rest.includes("/")) names.add(rest);
60
+ }
61
+ }
62
+ return [...names];
63
+ },
64
+ async listSubdirs(p) {
65
+ const prefix = p.replace(/\/+$/, "") + "/";
66
+ const names = new Set<string>();
67
+ for (const f of Object.keys(files)) {
68
+ if (f.startsWith(prefix)) {
69
+ const rest = f.slice(prefix.length);
70
+ const slash = rest.indexOf("/");
71
+ if (slash > 0) names.add(rest.slice(0, slash));
72
+ }
73
+ }
74
+ return [...names];
75
+ },
76
+ async exists(p) {
77
+ return p in files;
78
+ },
79
+ async remove(p) {
80
+ delete files[p];
81
+ },
82
+ async importModule(p) {
83
+ throw new Error(`no module registered for ${p}`);
84
+ },
85
+ };
86
+ }
87
+
88
+ /** An OpenAPI surface whose operationIds exercise Biome's natural, case-sensitive import sort
89
+ * (`getAgentInstructions` < `getAgenticRegistry` < `getAgentSkill`; `item2` < `item10`) — the exact
90
+ * collation the controller's delegate imports must reproduce to pass `organizeImports`. The
91
+ * path→operationId assignment is deliberately *scrambled* relative to that collation: `collectOperations`
92
+ * yields ops in sorted-path order (`/a`…`/e`), so the emitter's own `naturalCompare` sort — not the
93
+ * input order — is what must reorder the imports into Biome's canonical sequence. A broken/removed
94
+ * sort would leave them in path order and fail the `organizeImports` assist, so this input actually
95
+ * regression-guards `naturalCompare` rather than passing on already-sorted data. */
96
+ const OPENAPI = JSON.stringify({
97
+ openapi: "3.0.0",
98
+ info: { title: "guard", version: "1" },
99
+ paths: {
100
+ "/a": { get: { operationId: "item10", responses: { "200": { description: "ok" } } } },
101
+ "/b": { get: { operationId: "getAgentSkill", responses: { "200": { description: "ok" } } } },
102
+ "/c": { get: { operationId: "getAgentInstructions", responses: { "200": { description: "ok" } } } },
103
+ "/d": { get: { operationId: "getAgenticRegistry", responses: { "200": { description: "ok" } } } },
104
+ "/e": { get: { operationId: "item2", responses: { "200": { description: "ok" } } } },
105
+ },
106
+ });
107
+
108
+ const BPMN = `<bpmn:process id="p" xmlns:bpmn="x" xmlns:zeebe="y"><bpmn:serviceTask id="T">` +
109
+ `<bpmn:extensionElements><zeebe:taskDefinition type="demo.do" /></bpmn:extensionElements>` +
110
+ `</bpmn:serviceTask></bpmn:process>`;
111
+
112
+ /** A populated app (workers + domain types + declared metadata + an API surface) and an empty app
113
+ * (no workers/types/messages) — together they cover both the populated (`interface X { ... }`) and
114
+ * the empty (`Record<never, never>`) branches of every emitter reachable through `runGen`. */
115
+ const FIXTURES: Record<string, Record<string, string>> = {
116
+ populated: {
117
+ "/app/nano.app.json": JSON.stringify({
118
+ id: "demo",
119
+ data: { default: "app" },
120
+ models: { processes: ["processes/*.bpmn"] },
121
+ types: { greeting: { table: "greetings", fields: { who: { type: "string" } } } },
122
+ api: { spec: "openapi.json" },
123
+ }),
124
+ "/app/processes/p.bpmn": BPMN,
125
+ "/app/openapi.json": OPENAPI,
126
+ },
127
+ empty: {
128
+ "/app/nano.app.json": JSON.stringify({
129
+ id: "empty",
130
+ data: { default: "app" },
131
+ models: { processes: ["processes/*.bpmn"] },
132
+ }),
133
+ "/app/processes/p.bpmn": `<bpmn:process id="p" xmlns:bpmn="x"></bpmn:process>`,
134
+ },
135
+ };
136
+
137
+ /** Resolve the scaffold's own Biome binary — the exact linter a scaffolded app runs. */
138
+ async function resolveBiomeBin(): Promise<string> {
139
+ return fileURLToPath(await import.meta.resolve("@biomejs/biome/bin/biome"));
140
+ }
141
+
142
+ /** Lint `dirs` under `work` with the scaffold template's *exact* ruleset (plugin included), scoped
143
+ * to the generated dirs and with the formatter disabled — the generated code is intentionally
144
+ * console/deno-styled (ADR 0053 byte-parity), which the scaffold scopes away from its formatter, so
145
+ * the guard tracks lint-rule + import-organizing (assist) cleanliness only. */
146
+ function lintUnderScaffold(
147
+ work: string,
148
+ templateDir: string,
149
+ biomeBin: string,
150
+ dirs: string[],
151
+ ): { status: number | null; stdout: string; stderr: string } {
152
+ const templateBiome = JSON.parse(readFileSync(join(templateDir, "biome.json"), "utf8"));
153
+ // Copy the scaffold's GritQL plugin (bans `as` assertions) next to the config so its relative
154
+ // path resolves, then reuse the template's exact ruleset — force-scoped to the generated dirs so
155
+ // the guard tracks the scaffold standard without depending on the template's own `includes`.
156
+ cpSync(join(templateDir, "plugins"), join(work, "plugins"), { recursive: true });
157
+ const config = {
158
+ ...templateBiome,
159
+ $schema: undefined,
160
+ root: true,
161
+ files: { includes: dirs.map((d) => `${d}/**/*.ts`) },
162
+ formatter: { enabled: false },
163
+ linter: { ...templateBiome.linter, includes: dirs.map((d) => `${d}/**/*.ts`) },
164
+ };
165
+ writeFileSync(join(work, "biome.json"), `${JSON.stringify(config, null, "\t")}\n`);
166
+ // Run with cwd = work so Biome auto-discovers this `root: true` config (which also stops it
167
+ // walking up into any ancestor config). Passing --config-path *and* a path under it would load
168
+ // the config twice and error with "nested root configuration", so we rely on discovery.
169
+ return spawnSync(process.execPath, [biomeBin, "check", "."], { cwd: work, encoding: "utf8" });
170
+ }
171
+
172
+ test("generated nano-generated/ code passes the scaffold's Biome lint ruleset", nodeOnly, async () => {
173
+ const here = dirname(fileURLToPath(import.meta.url));
174
+ const templateDir = join(here, "..", "..", "..", "..", "create-urban-app", "template");
175
+ const biomeBin = await resolveBiomeBin();
176
+
177
+ const work = mkdtempSync(join(tmpdir(), "urban-genlint-"));
178
+ try {
179
+ const dirs: string[] = [];
180
+ const perFixture: Record<string, number> = {};
181
+ let tsFileCount = 0;
182
+ for (const [name, files] of Object.entries(FIXTURES)) {
183
+ const io = memIO({ ...files });
184
+ const res = await runGen({ root: "/app", io });
185
+ const outDir = join(work, name);
186
+ mkdirSync(outDir, { recursive: true });
187
+ dirs.push(name);
188
+ perFixture[name] = 0;
189
+ for (const artifact of res.artifacts) {
190
+ if (!/\.ts$/.test(artifact.path)) continue; // .ts + .d.ts, skip .sql/.json/.md
191
+ const fileName = artifact.path.split("/").pop();
192
+ if (!fileName) continue;
193
+ writeFileSync(join(outDir, fileName), io.files[`/app/${artifact.path}`]);
194
+ perFixture[name]++;
195
+ tsFileCount++;
196
+ }
197
+ }
198
+
199
+ // A fixture that silently produced no TypeScript artifacts would make the whole guard vacuous
200
+ // (Biome would report `Checked 0 files` yet still satisfy `>= 0`). Assert each fixture emitted a
201
+ // nonzero set *before* trusting Biome's own file count.
202
+ for (const [name, count] of Object.entries(perFixture)) {
203
+ assert.ok(count > 0, `fixture "${name}" produced no TypeScript artifacts — the guard is vacuous`);
204
+ }
205
+
206
+ const run = lintUnderScaffold(work, templateDir, biomeBin, dirs);
207
+
208
+ // The guard is only meaningful if Biome actually inspected the generated files — a mis-scoped
209
+ // config that silently checks nothing must not pass vacuously.
210
+ const checked = /Checked (\d+) files/.exec(run.stdout);
211
+ assert.ok(checked && Number(checked[1]) >= tsFileCount, `Biome did not check the generated files:\n${run.stdout}`);
212
+ assert.equal(run.status, 0, `Biome flagged generated code:\n${run.stdout}\n${run.stderr}`);
213
+ } finally {
214
+ rmSync(work, { recursive: true, force: true });
215
+ }
216
+ });
217
+
218
+ test("every emitter branch (populated, empty, and the data-path runtime wrappers) is lint-clean", nodeOnly, async () => {
219
+ const here = dirname(fileURLToPath(import.meta.url));
220
+ const templateDir = join(here, "..", "..", "..", "..", "create-urban-app", "template");
221
+ const biomeBin = await resolveBiomeBin();
222
+
223
+ // `runGen` emits only the `*.d.ts` type maps + the API runtime. The *data path* (`urban data`,
224
+ // `dataops.ts`) additionally materializes the runtime wrappers `workers.ts`, `messages.ts`, and the
225
+ // single-/multi-source `domain.ts` bindings into `nano-generated/`, so the scaffold's
226
+ // `nano-generated/**/*.ts` lint scope covers them too. Those wrappers carry the pass-through
227
+ // `as`-casts (and, multi-source, a keyed accessor) that only this plugin/assist ruleset can catch —
228
+ // exercise them here alongside every populated and empty (`Record<never, never>`) emitter branch so
229
+ // a regression in any of them fails at this guard, not only in a scaffolded user's project.
230
+ const sources: SourceSchema[] = [
231
+ {
232
+ source: "app",
233
+ tables: [{
234
+ name: "customers",
235
+ kind: "table",
236
+ columns: [{ name: "id", type: "INTEGER", notNull: true, primaryKey: true }],
237
+ indexes: [],
238
+ foreignKeys: [],
239
+ }],
240
+ },
241
+ {
242
+ source: "analytics",
243
+ tables: [{
244
+ name: "events",
245
+ kind: "table",
246
+ columns: [{ name: "id", type: "INTEGER", notNull: true, primaryKey: true }],
247
+ indexes: [],
248
+ foreignKeys: [],
249
+ }],
250
+ },
251
+ ];
252
+
253
+ // An empty OpenAPI document (zero operations) → the empty-branch of the API emitters.
254
+ const EMPTY_SPEC: OpenApiDoc = { openapi: "3.0.0", info: { title: "empty", version: "1" }, paths: {} };
255
+
256
+ const files: Record<string, string> = {
257
+ // data-path runtime wrappers (the `as`-cast / keyed-accessor carriers):
258
+ "workers.ts": emitWorkerBindingsRuntime(),
259
+ "messages.ts": emitMessageBindingsRuntime(),
260
+ "domain-single.ts": emitDomainBindings([sources[0]], "app"),
261
+ "domain-multi.ts": emitDomainBindings(sources, "app"),
262
+ // populated emitter branches:
263
+ "worker-io-populated.d.ts": emitWorkerBindings(
264
+ [{ taskType: "review", inputType: "Order", outputType: "Order", headerKeys: ["priority", "x-flag"] }],
265
+ ["Order"],
266
+ ),
267
+ "message-io-populated.d.ts": emitMessageBindings([{ messageName: "approved", inputType: "Order" }], ["Order"]),
268
+ "meta-populated.ts": emitMeta([
269
+ { key: "classification", value: "internal" },
270
+ { key: "data-classification", value: "pii" },
271
+ ]),
272
+ "domain-rows-populated.d.ts": emitDomainModel(sources, "app", {
273
+ greeting: { fields: { who: { type: "string" } } },
274
+ EmptyThing: { fields: {} }, // a declared type with no fields → the `Record<never, never>` registry fallback
275
+ }),
276
+ // empty emitter branches (the `Record<never, never>` fallbacks):
277
+ "worker-io-empty.d.ts": emitWorkerBindings([], []),
278
+ "message-io-empty.d.ts": emitMessageBindings([], []),
279
+ "meta-empty.ts": emitMeta([]),
280
+ "domain-rows-empty.d.ts": emitDomainDts([]),
281
+ // a *multi-source* schema where one source has NO tables → the multi-source empty-source
282
+ // `Record<never, never>` fallback in `emitDomainDtsForSources` (domain.ts), which the
283
+ // single-/no-source `emitDomainDts([])` above never reaches:
284
+ "domain-rows-empty-source.d.ts": emitDomainModel([sources[0], { source: "audit", tables: [] }], "app", {}),
285
+ // an empty OpenAPI spec (zero operations) → the `ApiOperations = Record<never, never>` fallback
286
+ // (not an empty `interface`) and a delegate-less controller:
287
+ "api-io-empty.d.ts": emitApiBindings(EMPTY_SPEC),
288
+ "operations-empty.ts": emitApiController(EMPTY_SPEC),
289
+ };
290
+
291
+ const work = mkdtempSync(join(tmpdir(), "urban-genwrap-"));
292
+ try {
293
+ const outDir = join(work, "gen");
294
+ mkdirSync(outDir, { recursive: true });
295
+ for (const [name, content] of Object.entries(files)) writeFileSync(join(outDir, name), content);
296
+
297
+ const run = lintUnderScaffold(work, templateDir, biomeBin, ["gen"]);
298
+
299
+ const expected = Object.keys(files).length;
300
+ const checked = /Checked (\d+) files/.exec(run.stdout);
301
+ assert.ok(
302
+ checked && Number(checked[1]) >= expected,
303
+ `Biome did not check every branch/wrapper file (expected >= ${expected}):\n${run.stdout}`,
304
+ );
305
+ assert.equal(run.status, 0, `Biome flagged a generated emitter branch:\n${run.stdout}\n${run.stderr}`);
306
+ } finally {
307
+ rmSync(work, { recursive: true, force: true });
308
+ }
309
+ });
310
+
311
+ /** Lint files laid out at `nano-generated/` using the template's `biome.json` *as shipped* — its own
312
+ * `files`/`linter.includes` and its formatter (enabled, with the `!nano-generated/**` exclusion),
313
+ * only flipping `root` (so Biome doesn't discover the monorepo's root config) and dropping the
314
+ * node_modules-relative `$schema`. Scoping the check to `nano-generated` lets Biome discover this
315
+ * config from `cwd` while checking only the generated tree. `lintUnderScaffold` above deliberately
316
+ * re-scopes `includes` and disables the formatter, so it can't catch a template regression that drops
317
+ * the `nano-generated/**` lint include or the formatter's `!nano-generated/**` exclusion — this can. */
318
+ function lintWithTemplateConfigVerbatim(
319
+ work: string,
320
+ templateDir: string,
321
+ biomeBin: string,
322
+ ): { status: number | null; stdout: string; stderr: string } {
323
+ const templateBiome = JSON.parse(readFileSync(join(templateDir, "biome.json"), "utf8"));
324
+ cpSync(join(templateDir, "plugins"), join(work, "plugins"), { recursive: true });
325
+ const config = { ...templateBiome, $schema: undefined, root: true };
326
+ writeFileSync(join(work, "biome.json"), `${JSON.stringify(config, null, "\t")}\n`);
327
+ return spawnSync(process.execPath, [biomeBin, "check", "nano-generated"], { cwd: work, encoding: "utf8" });
328
+ }
329
+
330
+ test("both scaffold templates lint (and format-exclude) nano-generated/ code with their biome.json as shipped", nodeOnly, async () => {
331
+ const here = dirname(fileURLToPath(import.meta.url));
332
+ const biomeBin = await resolveBiomeBin();
333
+
334
+ // Deliberately console/deno-styled (2-space, quoted keys, `as`-cast carriers) generated output: it
335
+ // is lint-clean under the scaffold ruleset yet *not* Biome's default (tab) format — so if a template
336
+ // ever dropped its formatter `!nano-generated/**` exclusion, `biome check` would report a format diff
337
+ // here and fail, and if it dropped the `nano-generated/**` lint include Biome would check 0 files.
338
+ const generated: Record<string, string> = {
339
+ "workers.ts": emitWorkerBindingsRuntime(),
340
+ "messages.ts": emitMessageBindingsRuntime(),
341
+ };
342
+
343
+ for (const template of ["template", "template-code-first"]) {
344
+ const templateDir = join(here, "..", "..", "..", "..", "create-urban-app", template);
345
+ const work = mkdtempSync(join(tmpdir(), `urban-tmpl-${template}-`));
346
+ try {
347
+ const genDir = join(work, "nano-generated");
348
+ mkdirSync(genDir, { recursive: true });
349
+ for (const [name, content] of Object.entries(generated)) writeFileSync(join(genDir, name), content);
350
+
351
+ const run = lintWithTemplateConfigVerbatim(work, templateDir, biomeBin);
352
+
353
+ // The template's own `nano-generated/**/*.ts` include must actually pick the files up — a
354
+ // dropped include would leave Biome checking 0 files while still exiting 0 (vacuous pass).
355
+ const checked = /Checked (\d+) files/.exec(run.stdout);
356
+ assert.ok(
357
+ checked && Number(checked[1]) >= Object.keys(generated).length,
358
+ `[${template}] template biome.json did not lint nano-generated/ via its own includes:\n${run.stdout}`,
359
+ );
360
+ assert.equal(
361
+ run.status,
362
+ 0,
363
+ `[${template}] template biome.json flagged (lint) or reformatted generated code:\n${run.stdout}\n${run.stderr}`,
364
+ );
365
+ } finally {
366
+ rmSync(work, { recursive: true, force: true });
367
+ }
368
+ }
369
+ });
@@ -15,7 +15,6 @@ const GOLD_EMPTY =
15
15
  "// each named message maps to the TS type of its payload, so the typed\n" +
16
16
  "// `publishMessage` types a call by its message name. Do not edit — regenerated\n" +
17
17
  "// from the model. Erased to plain JS at compile.\n" +
18
- "// eslint-disable\n" +
19
18
  "\n" +
20
19
  "\n" +
21
20
  "/** Untyped fallback payload for a message that declares no envelope type. */\n" +
@@ -26,7 +25,7 @@ const GOLD_EMPTY =
26
25
  "export type MessageName = string;\n" +
27
26
  "\n" +
28
27
  "/** Payload (`variables`) per declared message, keyed by `name`. */\n" +
29
- "export interface MessagePayloads {}\n";
28
+ "export type MessagePayloads = Record<never, never>;\n";
30
29
 
31
30
  const GOLD_NAMESONLY =
32
31
  "// AUTO-GENERATED by nanobpmn from the process model (ADR 0040 slice 2).\n" +
@@ -34,7 +33,6 @@ const GOLD_NAMESONLY =
34
33
  "// each named message maps to the TS type of its payload, so the typed\n" +
35
34
  "// `publishMessage` types a call by its message name. Do not edit — regenerated\n" +
36
35
  "// from the model. Erased to plain JS at compile.\n" +
37
- "// eslint-disable\n" +
38
36
  "\n" +
39
37
  "\n" +
40
38
  "/** Untyped fallback payload for a message that declares no envelope type. */\n" +
@@ -45,7 +43,7 @@ const GOLD_NAMESONLY =
45
43
  "export type MessageName = \"OrderPlaced\" | \"OrderShipped\";\n" +
46
44
  "\n" +
47
45
  "/** Payload (`variables`) per declared message, keyed by `name`. */\n" +
48
- "export interface MessagePayloads {}\n";
46
+ "export type MessagePayloads = Record<never, never>;\n";
49
47
 
50
48
  const GOLD_TYPED =
51
49
  "// AUTO-GENERATED by nanobpmn from the process model (ADR 0040 slice 2).\n" +
@@ -53,7 +51,6 @@ const GOLD_TYPED =
53
51
  "// each named message maps to the TS type of its payload, so the typed\n" +
54
52
  "// `publishMessage` types a call by its message name. Do not edit — regenerated\n" +
55
53
  "// from the model. Erased to plain JS at compile.\n" +
56
- "// eslint-disable\n" +
57
54
  "\n" +
58
55
  "import type { DomainTypes } from \"./domain-rows.d.ts\";\n" +
59
56
  "\n" +
@@ -75,7 +72,6 @@ const GOLD_MIXED =
75
72
  "// each named message maps to the TS type of its payload, so the typed\n" +
76
73
  "// `publishMessage` types a call by its message name. Do not edit — regenerated\n" +
77
74
  "// from the model. Erased to plain JS at compile.\n" +
78
- "// eslint-disable\n" +
79
75
  "\n" +
80
76
  "import type { DomainTypes } from \"./domain-rows.d.ts\";\n" +
81
77
  "\n" +
@@ -104,8 +104,7 @@ export function emitMessageBindings(
104
104
  "// The bridge from a `bpmn:message`'s data envelope to the message type system:\n" +
105
105
  "// each named message maps to the TS type of its payload, so the typed\n" +
106
106
  "// `publishMessage` types a call by its message name. Do not edit — regenerated\n" +
107
- "// from the model. Erased to plain JS at compile.\n" +
108
- "// eslint-disable\n";
107
+ "// from the model. Erased to plain JS at compile.\n";
109
108
 
110
109
  const needsRegistry = payloads.length > 0;
111
110
  const importTypes = needsRegistry
@@ -114,7 +113,7 @@ export function emitMessageBindings(
114
113
 
115
114
  const payloadsIface = payloads.length > 0
116
115
  ? `export interface MessagePayloads {\n${payloads.join("\n")}\n}\n`
117
- : `export interface MessagePayloads {}\n`;
116
+ : `export type MessagePayloads = Record<never, never>;\n`;
118
117
 
119
118
  return `${header}\n` +
120
119
  importTypes +
@@ -182,11 +181,10 @@ export function emitMessageBindingsRuntime(): string {
182
181
  return "// AUTO-GENERATED by nanobpmn (ADR 0040 slice 2): the typed `publishMessage`.\n" +
183
182
  "// Re-exports the worker SDK's `publishMessage` and overrides it with a\n" +
184
183
  "// messageName-keyed typed signature (variables typed from the message's declared\n" +
185
- "// envelope). Erased to a pass-through at runtime. Do not edit.\n" +
186
- "// eslint-disable\n\n" +
187
- `import { publishMessage as publishMessageRaw } from "./worker-sdk.ts";\n` +
184
+ "// envelope). Erased to a pass-through at runtime. Do not edit.\n\n" +
185
+ `import type { MessageName, MessagePayloads, MessageVars } from "./${MESSAGE_BINDINGS_DTS}";\n` +
188
186
  `import type { PublishMessageOptions, PublishMessageResult } from "./worker-sdk.ts";\n` +
189
- `import type { MessageName, MessagePayloads, MessageVars } from "./${MESSAGE_BINDINGS_DTS}";\n\n` +
187
+ `import { publishMessage as publishMessageRaw } from "./worker-sdk.ts";\n\n` +
190
188
  `export type { PublishMessageOptions, PublishMessageResult } from "./worker-sdk.ts";\n\n` +
191
189
  `type PayloadFor<K extends MessageName> = K extends keyof MessagePayloads ? MessagePayloads[K] : MessageVars;\n\n` +
192
190
  `/**\n` +
@@ -199,6 +197,7 @@ export function emitMessageBindingsRuntime(): string {
199
197
  ` name: K,\n` +
200
198
  ` opts?: PublishMessageOptions<PayloadFor<K> & object>,\n` +
201
199
  `): Promise<PublishMessageResult> {\n` +
200
+ ` // biome-ignore lint/plugin: generic-to-raw erasure at the message-SDK boundary; the wire stays untyped JSON (ADR 0040 slice 2).\n` +
202
201
  ` return publishMessageRaw(name, opts as unknown as PublishMessageOptions);\n` +
203
202
  `}\n`;
204
203
  }
@@ -9,7 +9,7 @@ const HEADER =
9
9
  "// model-metadata accessor. Each `nano:meta` key/value the models declare becomes\n" +
10
10
  "// a checked `AppMeta` property, so `meta(key)` is typed and app-wide queryable\n" +
11
11
  "// without a second store. Do not edit — regenerated from the model.\n" +
12
- "// eslint-disable\n\n" +
12
+ "\n" +
13
13
  "/** Every model-level metadata key the App's models declare (ADR 0040 §5). */\n";
14
14
  const ACCESSOR =
15
15
  "\n/** Read a model-level metadata value. A declared key is typed; a dynamic key\n" +
@@ -17,37 +17,37 @@ const ACCESSOR =
17
17
  "export function meta<K extends keyof AppMeta>(key: K): AppMeta[K];\n" +
18
18
  "export function meta(key: string): string | undefined;\n" +
19
19
  "export function meta(key: string): string | undefined {\n" +
20
- " return (appMeta as Record<string, string>)[key];\n" +
20
+ " return Reflect.get(appMeta, key);\n" +
21
21
  "}\n";
22
22
 
23
23
  const GOLDEN_EMPTY = HEADER +
24
- "export interface AppMeta {}\n\n" +
25
- "export const appMeta: AppMeta = Object.create(null) as AppMeta;\n" +
24
+ "export type AppMeta = Record<never, never>;\n\n" +
25
+ "export const appMeta: AppMeta = Object.create(null);\n" +
26
26
  ACCESSOR;
27
27
 
28
28
  const GOLDEN_MULTI = HEADER +
29
29
  "export interface AppMeta {\n classification: string;\n owner: string;\n}\n\n" +
30
30
  "export const appMeta: AppMeta = (() => {\n" +
31
- " const m: Record<string, string> = Object.create(null);\n" +
32
- ' m["classification"] = "internal";\n' +
33
- ' m["owner"] = "ops";\n' +
34
- " return m as AppMeta;\n})();\n" +
31
+ " const m: AppMeta = Object.create(null);\n" +
32
+ ' m.classification = "internal";\n' +
33
+ ' m.owner = "ops";\n' +
34
+ " return m;\n})();\n" +
35
35
  ACCESSOR;
36
36
 
37
37
  const GOLDEN_NONIDENT = HEADER +
38
38
  'export interface AppMeta {\n "data-classification": string;\n}\n\n' +
39
39
  "export const appMeta: AppMeta = (() => {\n" +
40
- " const m: Record<string, string> = Object.create(null);\n" +
40
+ " const m: AppMeta = Object.create(null);\n" +
41
41
  ' m["data-classification"] = "pii";\n' +
42
- " return m as AppMeta;\n})();\n" +
42
+ " return m;\n})();\n" +
43
43
  ACCESSOR;
44
44
 
45
45
  const GOLDEN_PROTO = HEADER +
46
46
  "export interface AppMeta {\n __proto__: string;\n}\n\n" +
47
47
  "export const appMeta: AppMeta = (() => {\n" +
48
- " const m: Record<string, string> = Object.create(null);\n" +
48
+ " const m: AppMeta = Object.create(null);\n" +
49
49
  ' m["__proto__"] = "danger";\n' +
50
- " return m as AppMeta;\n})();\n" +
50
+ " return m;\n})();\n" +
51
51
  ACCESSOR;
52
52
 
53
53
  test("emitMeta emits the empty accessor byte-for-byte (matches console)", () => {
@@ -135,6 +135,6 @@ test("deriveMeta folds across models in path order (last write wins)", () => {
135
135
  { path: "processes/a.bpmn", xml: mk("a", "staging") },
136
136
  ];
137
137
  const content = deriveMeta(models)[0].content;
138
- assert.match(content, /m\["env"\] = "prod";/);
138
+ assert.match(content, /m\.env = "prod";/);
139
139
  assert.doesNotMatch(content, /"staging"/);
140
140
  });
@@ -86,24 +86,27 @@ export function emitMeta(metas: MetaDecl[]): string {
86
86
  "// AUTO-GENERATED by nanobpmn from the process model (ADR 0040 §5): the typed\n" +
87
87
  "// model-metadata accessor. Each `nano:meta` key/value the models declare becomes\n" +
88
88
  "// a checked `AppMeta` property, so `meta(key)` is typed and app-wide queryable\n" +
89
- "// without a second store. Do not edit — regenerated from the model.\n" +
90
- "// eslint-disable\n\n";
89
+ "// without a second store. Do not edit — regenerated from the model.\n\n";
91
90
  const iface = keys.length > 0
92
91
  ? `export interface AppMeta {\n${keys.map((k) => ` ${propKey(k)}: string;`).join("\n")}\n}\n`
93
- : `export interface AppMeta {}\n`;
92
+ : `export type AppMeta = Record<never, never>;\n`;
93
+ // Dot-access satisfies Biome's `useLiteralKeys`, but `__proto__` must stay a computed key: dot
94
+ // access would trip `noProto` and (off a null-prototype dict) change semantics.
95
+ const assignTarget = (k: string) =>
96
+ isIdent(k) && k !== "__proto__" ? `m.${k}` : `m[${JSON.stringify(k)}]`;
94
97
  const constDecl = keys.length > 0
95
98
  ? `export const appMeta: AppMeta = (() => {\n` +
96
- ` const m: Record<string, string> = Object.create(null);\n` +
97
- keys.map((k) => ` m[${JSON.stringify(k)}] = ${JSON.stringify(folded[k])};`).join("\n") +
98
- `\n return m as AppMeta;\n})();\n`
99
- : `export const appMeta: AppMeta = Object.create(null) as AppMeta;\n`;
99
+ ` const m: AppMeta = Object.create(null);\n` +
100
+ keys.map((k) => ` ${assignTarget(k)} = ${JSON.stringify(folded[k])};`).join("\n") +
101
+ `\n return m;\n})();\n`
102
+ : `export const appMeta: AppMeta = Object.create(null);\n`;
100
103
  const accessor =
101
104
  `\n/** Read a model-level metadata value. A declared key is typed; a dynamic key\n` +
102
105
  ` * returns \`string | undefined\`. */\n` +
103
106
  `export function meta<K extends keyof AppMeta>(key: K): AppMeta[K];\n` +
104
107
  `export function meta(key: string): string | undefined;\n` +
105
108
  `export function meta(key: string): string | undefined {\n` +
106
- ` return (appMeta as Record<string, string>)[key];\n` +
109
+ ` return Reflect.get(appMeta, key);\n` +
107
110
  `}\n`;
108
111
  return `${header}/** Every model-level metadata key the App's models declare (ADR 0040 §5). */\n` +
109
112
  iface + "\n" + constDecl + accessor;
@@ -72,7 +72,7 @@ test("emitWorkerBindings falls back to string union + no import when nothing is
72
72
  const out = emitWorkerBindings([{ taskType: "orders.notify" }], []);
73
73
  assert.match(out, /export type WorkerTaskType = "orders.notify";/);
74
74
  assert.doesNotMatch(out, /import type \{ DomainTypes \}/);
75
- assert.match(out, /export interface WorkerInputs \{\}/);
75
+ assert.match(out, /export type WorkerInputs = Record<never, never>;/);
76
76
  });
77
77
 
78
78
  test("emitWorkerBindings unions distinct envelopes bound to one taskType (shared handler)", () => {
@@ -182,8 +182,7 @@ export function emitWorkerBindings(
182
182
  "// The bridge from the process model to the worker type system: each declared\n" +
183
183
  "// worker's `taskType` maps to the TS type of its input payload (`job.variables`)\n" +
184
184
  "// and result, so the typed `defineWorker` types a handler by its job type. Do\n" +
185
- "// not edit — regenerated from the manifest. Erased to plain JS at compile.\n" +
186
- "// eslint-disable\n";
185
+ "// not edit — regenerated from the manifest. Erased to plain JS at compile.\n";
187
186
 
188
187
  const needsRegistry = inputs.length > 0 || outputs.length > 0;
189
188
  const importTypes = needsRegistry
@@ -192,13 +191,13 @@ export function emitWorkerBindings(
192
191
 
193
192
  const inputsIface = inputs.length > 0
194
193
  ? `export interface WorkerInputs {\n${inputs.join("\n")}\n}\n`
195
- : `export interface WorkerInputs {}\n`;
194
+ : `export type WorkerInputs = Record<never, never>;\n`;
196
195
  const outputsIface = outputs.length > 0
197
196
  ? `export interface WorkerOutputs {\n${outputs.join("\n")}\n}\n`
198
- : `export interface WorkerOutputs {}\n`;
197
+ : `export type WorkerOutputs = Record<never, never>;\n`;
199
198
  const headersIface = headers.length > 0
200
199
  ? `export interface WorkerHeaders {\n${headers.join("\n")}\n}\n`
201
- : `export interface WorkerHeaders {}\n`;
200
+ : `export type WorkerHeaders = Record<never, never>;\n`;
202
201
 
203
202
  return `${header}\n` +
204
203
  importTypes +
@@ -373,11 +372,10 @@ export function emitWorkerBindingsRuntime(): string {
373
372
  "// Re-exports the worker SDK and overrides `defineWorker` with a taskType-keyed\n" +
374
373
  "// typed signature (job.variables, job.customHeaders + result typed from the\n" +
375
374
  "// worker's declared input/output/header contract). Erased to a pass-through at\n" +
376
- "// runtime. Do not edit.\n" +
377
- "// eslint-disable\n\n" +
378
- `import { defineWorker as defineWorkerRaw } from "./worker-sdk.ts";\n` +
375
+ "// runtime. Do not edit.\n\n" +
376
+ `import type { WorkerHdrs, WorkerHeaders, WorkerInputs, WorkerOutputs, WorkerTaskType, WorkerVars } from "./${WORKER_BINDINGS_DTS}";\n` +
379
377
  `import type { WorkerOptions } from "./worker-sdk.ts";\n` +
380
- `import type { WorkerInputs, WorkerOutputs, WorkerHeaders, WorkerTaskType, WorkerVars, WorkerHdrs } from "./${WORKER_BINDINGS_DTS}";\n\n` +
378
+ `import { defineWorker as defineWorkerRaw } from "./worker-sdk.ts";\n\n` +
381
379
  `export * from "./worker-sdk.ts";\n\n` +
382
380
  `type InFor<K extends WorkerTaskType> = K extends keyof WorkerInputs ? WorkerInputs[K] : WorkerVars;\n` +
383
381
  `type OutFor<K extends WorkerTaskType> = K extends keyof WorkerOutputs ? WorkerOutputs[K] : WorkerVars;\n` +
@@ -392,6 +390,7 @@ export function emitWorkerBindingsRuntime(): string {
392
390
  `export function defineWorker<K extends WorkerTaskType>(\n` +
393
391
  ` opts: { type: K } & WorkerOptions<InFor<K> & object, OutFor<K> & object, HdrFor<K> & object>,\n` +
394
392
  `): void {\n` +
393
+ ` // biome-ignore lint/plugin: generic-to-raw erasure at the worker-SDK boundary; the wire stays untyped JSON (ADR 0033 §3).\n` +
395
394
  ` defineWorkerRaw(opts as unknown as WorkerOptions);\n` +
396
395
  `}\n`;
397
396
  }