@nanobpm/urban 0.77.3 → 0.77.4
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/dist/toolkit/addConnector.js +8 -2
- package/dist/toolkit/scaffold/operations.js +11 -6
- package/dist/toolkit/scaffold/workers.js +12 -7
- package/dist/toolkit/scaffold.d.ts +12 -0
- package/dist/toolkit/scaffold.js +27 -2
- package/package.json +2 -2
- package/src/toolkit/addConnector.test.ts +18 -0
- package/src/toolkit/addConnector.ts +8 -2
- package/src/toolkit/derivers/generated-lint.test.ts +2 -2
- package/src/toolkit/scaffold/operations.test.ts +43 -0
- package/src/toolkit/scaffold/operations.ts +11 -6
- package/src/toolkit/scaffold/workers.test.ts +53 -1
- package/src/toolkit/scaffold/workers.ts +12 -7
- package/src/toolkit/scaffold.ts +27 -2
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// package (a subprocess) is the CLI's job, so this stays runtime-agnostic and
|
|
13
13
|
// unit-testable.
|
|
14
14
|
import { errorMessage, isRecord } from "../runtime/core/guards.js";
|
|
15
|
+
import { detectJsonIndent } from "./scaffold.js";
|
|
15
16
|
const MANIFEST_FILE = "nano-ide.ext.json";
|
|
16
17
|
function joinPath(a, b) {
|
|
17
18
|
// Trim both slash styles (as toolkit/gen.ts joinPath does) so a Windows-style
|
|
@@ -61,9 +62,11 @@ export async function addConnector(opts) {
|
|
|
61
62
|
if (!(await io.exists(appManifestPath))) {
|
|
62
63
|
throw new Error(`no ${manifestFile} at ${appManifestPath} — run this inside an Urban app.`);
|
|
63
64
|
}
|
|
65
|
+
let appRaw;
|
|
64
66
|
let app;
|
|
65
67
|
try {
|
|
66
|
-
|
|
68
|
+
appRaw = await io.readText(appManifestPath);
|
|
69
|
+
app = JSON.parse(appRaw);
|
|
67
70
|
}
|
|
68
71
|
catch (err) {
|
|
69
72
|
throw new Error(`failed to parse ${appManifestPath}: ${errorMessage(err)}`);
|
|
@@ -161,7 +164,10 @@ export async function addConnector(opts) {
|
|
|
161
164
|
workers.push(entry);
|
|
162
165
|
wired.push({ taskType: spec.type, alreadyPresent: false });
|
|
163
166
|
}
|
|
164
|
-
|
|
167
|
+
// Preserve the manifest's own indentation (default tab, matching the scaffold's Biome config)
|
|
168
|
+
// so enabling a connector never reformats nano.app.json out from under `npm run lint` — the same
|
|
169
|
+
// fidelity `urban gen` gives via detectJsonIndent, kept as the single source of truth.
|
|
170
|
+
await io.writeText(appManifestPath, `${JSON.stringify(app, null, detectJsonIndent(appRaw))}\n`);
|
|
165
171
|
return {
|
|
166
172
|
packId: pack.id,
|
|
167
173
|
wired,
|
|
@@ -29,16 +29,21 @@ export function renderOperationStub(operationId, dir = DEFAULT_OPERATIONS_DIR) {
|
|
|
29
29
|
`// (ADR 0059). This file is yours to edit — \`urban gen\` will never overwrite it. The\n` +
|
|
30
30
|
`// generated controller (nano-generated/controller.ts) imports this default export and\n` +
|
|
31
31
|
`// type-checks it against the spec, so the signature cannot drift. Implement the body below\n` +
|
|
32
|
-
`// (the validated \`{ req, params, query, body }\` input + the injected \`app\`)
|
|
32
|
+
`// (the validated \`{ req, params, query, body }\` input + the injected \`app\`), replacing the\n` +
|
|
33
|
+
`// guard warn + throw.\n` +
|
|
33
34
|
`import { NotImplemented } from "@nanobpm/urban";\n` +
|
|
34
35
|
`import { defineOperation } from ${JSON.stringify(`${up}nano-generated/operations.ts`)};\n` +
|
|
35
36
|
`\n` +
|
|
36
37
|
`export default defineOperation(${key}, async (input, app) => {\n` +
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
`\t// \`input\` is the validated request (params/query/body/req); reach app state + services\n` +
|
|
39
|
+
`\t// through the injected \`app\` API. Structured logs via \`app.log.info("…", { … })\` are\n` +
|
|
40
|
+
`\t// auto-tagged with this request's { method, path, operationId } (ADR 0061).\n` +
|
|
41
|
+
`\t// Return \`{ status?, headers?, body? }\` (or nothing → 204). Replace the two lines below.\n` +
|
|
42
|
+
`\tapp.log.warn("operation not implemented", {\n` +
|
|
43
|
+
`\t\toperationId: ${key},\n` +
|
|
44
|
+
`\t\tmethod: input.req.method,\n` +
|
|
45
|
+
`\t});\n` +
|
|
46
|
+
`\tthrow new NotImplemented(${key});\n` +
|
|
42
47
|
`});\n`);
|
|
43
48
|
}
|
|
44
49
|
/**
|
|
@@ -38,21 +38,26 @@ export function renderWorkerStub(taskType, typedIn, typedOut) {
|
|
|
38
38
|
else if (typedOut)
|
|
39
39
|
generic = `<Record<string, unknown>, WorkerOutputs[${key}]>`;
|
|
40
40
|
const varsNote = typedIn
|
|
41
|
-
?
|
|
42
|
-
:
|
|
41
|
+
? `\t// \`job.variables\` is typed as WorkerInputs[${key}].`
|
|
42
|
+
: `\t// \`job.variables\` is an open Record<string, unknown> (no declared input type).`;
|
|
43
|
+
// The stub arrives lint-clean under the scaffold's own Biome config: tab-indented, and
|
|
44
|
+
// referencing both `job` and `app` (via the warn line) so `noUnusedFunctionParameters`
|
|
45
|
+
// does not fire before the author has filled in the body.
|
|
43
46
|
return (`// Handler stub for the \`${taskType}\` service task, scaffolded from the model (ADR 0056).\n` +
|
|
44
47
|
`// This file is yours to edit — \`urban gen\` will never overwrite it. Implement the body\n` +
|
|
45
|
-
`// below (use \`app.data.table(...)\` for state)
|
|
48
|
+
`// below (use \`app.data.table(...)\` for state), replacing the guard warn + throw so the\n` +
|
|
49
|
+
`// "worker not implemented" warning does not survive into a working handler.\n` +
|
|
46
50
|
`import type { AppJobHandler } from "@nanobpm/urban/worker";\n` +
|
|
47
51
|
generatedImport +
|
|
48
52
|
`\n` +
|
|
49
53
|
`const handler: AppJobHandler${generic} = async (job, app) => {\n` +
|
|
50
54
|
varsNote +
|
|
51
55
|
`\n` +
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
`\t// Reach app state and services through the injected \`app\` API.\n` +
|
|
57
|
+
`\t// Structured logs: \`app.log.info("done", { … })\` — auto-tagged with this job's\n` +
|
|
58
|
+
`\t// { jobKey, jobType, processInstanceKey, elementId } (ADR 0061).\n` +
|
|
59
|
+
`\tapp.log.warn("worker not implemented", { jobKey: job.jobKey });\n` +
|
|
60
|
+
`\tthrow new Error(${JSON.stringify(`worker not implemented: ${taskType}`)});\n` +
|
|
56
61
|
`};\n` +
|
|
57
62
|
`\n` +
|
|
58
63
|
`export default handler;\n`);
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import type { GenIO } from "./gen.ts";
|
|
2
2
|
import { type SkippedWorker, type StubManifestEntry } from "./scaffold/workers.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Detect the indentation of a JSON document from its first indented line, so a rewrite can
|
|
5
|
+
* reuse it (`"\t"` or an N-space string) instead of hard-coding one style. Falls back to a tab
|
|
6
|
+
* — the scaffold's Biome default — when the file is empty/single-line or uses no indentation.
|
|
7
|
+
*
|
|
8
|
+
* Any indent — spaces or tabs — is clamped to 10 characters because `JSON.stringify` silently
|
|
9
|
+
* truncates a string `space` argument to 10; returning a wider prefix would promise a fidelity the
|
|
10
|
+
* serializer cannot honor (e.g. an 11+-tab manifest would be rewritten at a different width than
|
|
11
|
+
* its input). Tab indentation (the scaffold default) and any realistic width (2/4 spaces) are
|
|
12
|
+
* unaffected.
|
|
13
|
+
*/
|
|
14
|
+
export declare function detectJsonIndent(json: string): string;
|
|
3
15
|
export interface ScaffoldOptions {
|
|
4
16
|
root: string;
|
|
5
17
|
io: GenIO;
|
package/dist/toolkit/scaffold.js
CHANGED
|
@@ -6,13 +6,38 @@ import { joinPath, readModels } from "./gen.js";
|
|
|
6
6
|
import { planWorkerScaffold, } from "./scaffold/workers.js";
|
|
7
7
|
import { planOperationScaffold } from "./scaffold/operations.js";
|
|
8
8
|
import { parseSpec } from "../openapi/spec.js";
|
|
9
|
+
/**
|
|
10
|
+
* Detect the indentation of a JSON document from its first indented line, so a rewrite can
|
|
11
|
+
* reuse it (`"\t"` or an N-space string) instead of hard-coding one style. Falls back to a tab
|
|
12
|
+
* — the scaffold's Biome default — when the file is empty/single-line or uses no indentation.
|
|
13
|
+
*
|
|
14
|
+
* Any indent — spaces or tabs — is clamped to 10 characters because `JSON.stringify` silently
|
|
15
|
+
* truncates a string `space` argument to 10; returning a wider prefix would promise a fidelity the
|
|
16
|
+
* serializer cannot honor (e.g. an 11+-tab manifest would be rewritten at a different width than
|
|
17
|
+
* its input). Tab indentation (the scaffold default) and any realistic width (2/4 spaces) are
|
|
18
|
+
* unaffected.
|
|
19
|
+
*/
|
|
20
|
+
export function detectJsonIndent(json) {
|
|
21
|
+
// Anchor on any line terminator, not just `\n`, so the width is detected regardless of the
|
|
22
|
+
// manifest's line endings: LF (`\n`), CRLF (`\r\n`, where `\n` already precedes the indent),
|
|
23
|
+
// bare-CR old-Mac (`\r`), and LFCR (`\n\r`) all resolve to the indentation that follows.
|
|
24
|
+
const m = json.match(/[\r\n]([ \t]+)\S/);
|
|
25
|
+
if (!m)
|
|
26
|
+
return "\t";
|
|
27
|
+
const indent = m[1];
|
|
28
|
+
return indent.length > 10 ? indent.slice(0, 10) : indent;
|
|
29
|
+
}
|
|
9
30
|
/** Scaffold write-once worker stubs from the model, wiring them into `manifest.workers[]`. */
|
|
10
31
|
export async function scaffoldWorkers(opts) {
|
|
11
32
|
const { root, io } = opts;
|
|
12
33
|
const write = opts.write ?? false;
|
|
13
34
|
const manifestFile = opts.manifestFile ?? "nano.app.json";
|
|
14
35
|
const manifestPath = joinPath(root, manifestFile);
|
|
15
|
-
const
|
|
36
|
+
const manifestRaw = await io.readText(manifestPath);
|
|
37
|
+
const manifest = JSON.parse(manifestRaw);
|
|
38
|
+
// Preserve the manifest's own indentation when we patch it back (default tab, matching the
|
|
39
|
+
// scaffold's Biome config), so wiring a worker never reformats the file out from under lint.
|
|
40
|
+
const manifestIndent = detectJsonIndent(manifestRaw);
|
|
16
41
|
const models = await readModels(root, io, manifest);
|
|
17
42
|
const declaredTypeIds = Object.keys(manifest.types ?? {});
|
|
18
43
|
const { plans, skipped } = planWorkerScaffold(models, manifest.workers ?? [], declaredTypeIds, manifest.externalTaskTypes ?? []);
|
|
@@ -49,7 +74,7 @@ export async function scaffoldWorkers(opts) {
|
|
|
49
74
|
...manifest,
|
|
50
75
|
workers: [...(manifest.workers ?? []), ...wired],
|
|
51
76
|
};
|
|
52
|
-
await io.writeText(manifestPath, `${JSON.stringify(withWorkers, null,
|
|
77
|
+
await io.writeText(manifestPath, `${JSON.stringify(withWorkers, null, manifestIndent)}\n`);
|
|
53
78
|
manifestPatched = true;
|
|
54
79
|
}
|
|
55
80
|
return { outcomes, skipped, wired, manifestPatched, write };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.77.
|
|
3
|
+
"version": "0.77.4",
|
|
4
4
|
"description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
"@nanobpm/nano-sdk": "^1.2.5",
|
|
104
104
|
"@nanobpm/workflow": "^0.14.0",
|
|
105
105
|
"bpmn-auto-layout": "^2.0.0-alpha.2",
|
|
106
|
-
"create-urban-app": "^0.15.
|
|
106
|
+
"create-urban-app": "^0.15.2",
|
|
107
107
|
"yaml": "^2.9.0"
|
|
108
108
|
},
|
|
109
109
|
"devDependencies": {
|
|
@@ -67,6 +67,24 @@ test("addConnector wires a pack-backed worker + a connection into nano.app.json"
|
|
|
67
67
|
});
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
+
test("addConnector preserves the manifest's existing indentation (no lint reformat)", async () => {
|
|
71
|
+
// Regression (nano-ide#454): the scaffold's Biome config formats nano.app.json with tabs, so a
|
|
72
|
+
// hard-coded 2-space rewrite here would turn `npm run lint` red the moment a connector is
|
|
73
|
+
// enabled in a fresh app. addConnector must round-trip the manifest's own indentation, like
|
|
74
|
+
// `urban gen` does via detectJsonIndent.
|
|
75
|
+
const tabManifest = `${JSON.stringify({ schemaVersion: 1, id: "t", name: "T" }, null, "\t")}\n`;
|
|
76
|
+
const io = memIO({ ...baseFiles(), "app/nano.app.json": tabManifest });
|
|
77
|
+
await addConnector({ root: "app", pkg: PKG, io });
|
|
78
|
+
const written = io.files["app/nano.app.json"];
|
|
79
|
+
assert.match(written, /\n\t"workers"/, "tab indentation is preserved");
|
|
80
|
+
assert.doesNotMatch(written, /\n {2}"workers"/, "no 2-space indentation was introduced");
|
|
81
|
+
|
|
82
|
+
const twoSpace = `${JSON.stringify({ schemaVersion: 1, id: "t", name: "T" }, null, 2)}\n`;
|
|
83
|
+
const io2 = memIO({ ...baseFiles(), "app/nano.app.json": twoSpace });
|
|
84
|
+
await addConnector({ root: "app", pkg: PKG, io: io2 });
|
|
85
|
+
assert.match(io2.files["app/nano.app.json"], /\n {2}"workers"/, "2-space indentation is preserved");
|
|
86
|
+
});
|
|
87
|
+
|
|
70
88
|
test("addConnector is idempotent — re-running does not duplicate entries", async () => {
|
|
71
89
|
const io = memIO(baseFiles());
|
|
72
90
|
await addConnector({ root: "app", pkg: PKG, io });
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import type { GenIO } from "./gen.ts";
|
|
16
16
|
import { errorMessage, isRecord } from "../runtime/core/guards.ts";
|
|
17
|
+
import { detectJsonIndent } from "./scaffold.ts";
|
|
17
18
|
|
|
18
19
|
/** A config field a pack declares (subset of ext-types `ConfigField`). */
|
|
19
20
|
interface PackConfigField {
|
|
@@ -116,13 +117,15 @@ export async function addConnector(opts: AddConnectorOptions): Promise<AddConnec
|
|
|
116
117
|
if (!(await io.exists(appManifestPath))) {
|
|
117
118
|
throw new Error(`no ${manifestFile} at ${appManifestPath} — run this inside an Urban app.`);
|
|
118
119
|
}
|
|
120
|
+
let appRaw: string;
|
|
119
121
|
let app: {
|
|
120
122
|
workers?: { taskType?: string; connector?: string; connection?: string; handler?: string; llm?: string }[];
|
|
121
123
|
connections?: Record<string, Record<string, unknown>>;
|
|
122
124
|
[k: string]: unknown;
|
|
123
125
|
};
|
|
124
126
|
try {
|
|
125
|
-
|
|
127
|
+
appRaw = await io.readText(appManifestPath);
|
|
128
|
+
app = JSON.parse(appRaw);
|
|
126
129
|
} catch (err) {
|
|
127
130
|
throw new Error(`failed to parse ${appManifestPath}: ${errorMessage(err)}`);
|
|
128
131
|
}
|
|
@@ -223,7 +226,10 @@ export async function addConnector(opts: AddConnectorOptions): Promise<AddConnec
|
|
|
223
226
|
wired.push({ taskType: spec.type, alreadyPresent: false });
|
|
224
227
|
}
|
|
225
228
|
|
|
226
|
-
|
|
229
|
+
// Preserve the manifest's own indentation (default tab, matching the scaffold's Biome config)
|
|
230
|
+
// so enabling a connector never reformats nano.app.json out from under `npm run lint` — the same
|
|
231
|
+
// fidelity `urban gen` gives via detectJsonIndent, kept as the single source of truth.
|
|
232
|
+
await io.writeText(appManifestPath, `${JSON.stringify(app, null, detectJsonIndent(appRaw))}\n`);
|
|
227
233
|
|
|
228
234
|
return {
|
|
229
235
|
packId: pack.id,
|
|
@@ -149,7 +149,7 @@ function lintUnderScaffold(
|
|
|
149
149
|
biomeBin: string,
|
|
150
150
|
dirs: string[],
|
|
151
151
|
): { status: number | null; stdout: string; stderr: string } {
|
|
152
|
-
const templateBiome = JSON.parse(readFileSync(join(templateDir, "
|
|
152
|
+
const templateBiome = JSON.parse(readFileSync(join(templateDir, "_biome.json"), "utf8"));
|
|
153
153
|
// Copy the scaffold's GritQL plugin (bans `as` assertions) next to the config so its relative
|
|
154
154
|
// path resolves, then reuse the template's exact ruleset — force-scoped to the generated dirs so
|
|
155
155
|
// the guard tracks the scaffold standard without depending on the template's own `includes`.
|
|
@@ -320,7 +320,7 @@ function lintWithTemplateConfigVerbatim(
|
|
|
320
320
|
templateDir: string,
|
|
321
321
|
biomeBin: string,
|
|
322
322
|
): { status: number | null; stdout: string; stderr: string } {
|
|
323
|
-
const templateBiome = JSON.parse(readFileSync(join(templateDir, "
|
|
323
|
+
const templateBiome = JSON.parse(readFileSync(join(templateDir, "_biome.json"), "utf8"));
|
|
324
324
|
cpSync(join(templateDir, "plugins"), join(work, "plugins"), { recursive: true });
|
|
325
325
|
const config = { ...templateBiome, $schema: undefined, root: true };
|
|
326
326
|
writeFileSync(join(work, "biome.json"), `${JSON.stringify(config, null, "\t")}\n`);
|
|
@@ -34,6 +34,49 @@ test("renderOperationStub adjusts the generated-import depth for a nested api.di
|
|
|
34
34
|
assert.match(stub, /import \{ defineOperation \} from "\.\.\/\.\.\/nano-generated\/operations\.ts";/);
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
+
test("operation stub is lint-clean under the scaffold config: tab-indented, both params referenced", () => {
|
|
38
|
+
// Guards the same two failure modes as the worker stub (nano-ide#454): the scaffold's Biome
|
|
39
|
+
// config formats with tabs and enables correctness/noUnusedFunctionParameters, so a
|
|
40
|
+
// space-indented body or an unused `input`/`app` makes a freshly generated operation fail the
|
|
41
|
+
// app's own `biome check`. Body lines must be tab-indented and both params read before the
|
|
42
|
+
// author fills in the body.
|
|
43
|
+
const stub = renderOperationStub("getInvoice");
|
|
44
|
+
const bodyLines = stub
|
|
45
|
+
.split("\n")
|
|
46
|
+
.filter((l) => l.startsWith(" ") || l.startsWith("\t"));
|
|
47
|
+
assert.ok(bodyLines.length > 0, "the stub has an indented body");
|
|
48
|
+
for (const line of bodyLines) {
|
|
49
|
+
assert.ok(line.startsWith("\t"), `body line is tab-indented, not space-indented: ${line}`);
|
|
50
|
+
}
|
|
51
|
+
assert.match(
|
|
52
|
+
stub,
|
|
53
|
+
/app\.log\.warn\("operation not implemented", \{\n\t\toperationId: "getInvoice",\n\t\tmethod: input\.req\.method,\n\t\}\);/,
|
|
54
|
+
);
|
|
55
|
+
// Guard the same Biome-width defect class the reviewer flagged (nano-ide#454): the generated
|
|
56
|
+
// stub is linted by the scaffold's `operations/**/*.ts` include at Biome's default 80-column
|
|
57
|
+
// width (tab = 2 columns), so a single-line log call that overruns 80 makes `urban gen` emit a
|
|
58
|
+
// file `biome check` immediately reports as needing formatting. Assert every non-comment code
|
|
59
|
+
// line fits, so any future template change that reintroduces an over-wide line fails here.
|
|
60
|
+
for (const line of stub.split("\n")) {
|
|
61
|
+
if (line.trim().startsWith("//")) continue;
|
|
62
|
+
const leadingTabs = line.length - line.replace(/^\t+/, "").length;
|
|
63
|
+
const width = leadingTabs * 2 + (line.length - leadingTabs);
|
|
64
|
+
assert.ok(width <= 80, `generated code line exceeds Biome's 80-column width (${width}): ${line}`);
|
|
65
|
+
}
|
|
66
|
+
// Guard the PII/credential-leak defect class (nano-ide#454): the stub must reference a
|
|
67
|
+
// non-sensitive identifier, never serialize the whole validated `input` (which carries
|
|
68
|
+
// params/query/body/req) into the NDJSON log line.
|
|
69
|
+
assert.doesNotMatch(stub, /app\.log\.warn\([^)]*,\s*input\s*\}/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("operation stub logs a non-sensitive identifier, not the raw input payload", () => {
|
|
73
|
+
// Red/Green guard for the operation-stub log-leak: an unedited 501 handler must not emit the
|
|
74
|
+
// validated request body/params/query (potential credentials or PII) to structured logs.
|
|
75
|
+
const stub = renderOperationStub("createInvoice");
|
|
76
|
+
assert.doesNotMatch(stub, /,\s*input\s*\}\)/);
|
|
77
|
+
assert.match(stub, /method: input\.req\.method/);
|
|
78
|
+
});
|
|
79
|
+
|
|
37
80
|
test("planOperationScaffold plans one stub per declared operationId", () => {
|
|
38
81
|
const plans = planOperationScaffold(doc);
|
|
39
82
|
const byId = Object.fromEntries(plans.map((p) => [p.operationId, p.handlerPath]));
|
|
@@ -44,16 +44,21 @@ export function renderOperationStub(operationId: string, dir: string = DEFAULT_O
|
|
|
44
44
|
`// (ADR 0059). This file is yours to edit — \`urban gen\` will never overwrite it. The\n` +
|
|
45
45
|
`// generated controller (nano-generated/controller.ts) imports this default export and\n` +
|
|
46
46
|
`// type-checks it against the spec, so the signature cannot drift. Implement the body below\n` +
|
|
47
|
-
`// (the validated \`{ req, params, query, body }\` input + the injected \`app\`)
|
|
47
|
+
`// (the validated \`{ req, params, query, body }\` input + the injected \`app\`), replacing the\n` +
|
|
48
|
+
`// guard warn + throw.\n` +
|
|
48
49
|
`import { NotImplemented } from "@nanobpm/urban";\n` +
|
|
49
50
|
`import { defineOperation } from ${JSON.stringify(`${up}nano-generated/operations.ts`)};\n` +
|
|
50
51
|
`\n` +
|
|
51
52
|
`export default defineOperation(${key}, async (input, app) => {\n` +
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
`\t// \`input\` is the validated request (params/query/body/req); reach app state + services\n` +
|
|
54
|
+
`\t// through the injected \`app\` API. Structured logs via \`app.log.info("…", { … })\` are\n` +
|
|
55
|
+
`\t// auto-tagged with this request's { method, path, operationId } (ADR 0061).\n` +
|
|
56
|
+
`\t// Return \`{ status?, headers?, body? }\` (or nothing → 204). Replace the two lines below.\n` +
|
|
57
|
+
`\tapp.log.warn("operation not implemented", {\n` +
|
|
58
|
+
`\t\toperationId: ${key},\n` +
|
|
59
|
+
`\t\tmethod: input.req.method,\n` +
|
|
60
|
+
`\t});\n` +
|
|
61
|
+
`\tthrow new NotImplemented(${key});\n` +
|
|
57
62
|
`});\n`
|
|
58
63
|
);
|
|
59
64
|
}
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
type ScaffoldWorker,
|
|
8
8
|
} from "./workers.ts";
|
|
9
9
|
import type { ModelSource } from "../derivers/worker-io.ts";
|
|
10
|
-
import { scaffoldWorkers } from "../scaffold.ts";
|
|
10
|
+
import { detectJsonIndent, scaffoldWorkers } from "../scaffold.ts";
|
|
11
11
|
import type { GenIO } from "../gen.ts";
|
|
12
12
|
|
|
13
13
|
// A service task with a data-envelope in/out contract; `type` is the taskType, and the
|
|
@@ -53,6 +53,43 @@ test("untyped stub imports no generated types and uses bare AppJobHandler", () =
|
|
|
53
53
|
assert.match(stub, /const handler: AppJobHandler = async \(job, app\) =>/);
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
+
test("stub is lint-clean under the scaffold config: tab-indented, both params referenced", () => {
|
|
57
|
+
// Guards the two ways `urban gen`'s stub used to fail a fresh scaffold's own `biome check`
|
|
58
|
+
// (nano-ide#451): space indentation (Biome defaults to tabs) and unused `job`/`app` params
|
|
59
|
+
// (correctness/noUnusedFunctionParameters). Body lines must be tab-indented and both params
|
|
60
|
+
// must be read before the author fills in the body.
|
|
61
|
+
const stub = renderWorkerStub("greet.hello", false, false);
|
|
62
|
+
const bodyLines = stub.split("\n").filter((l) => l.startsWith(" ") || l.startsWith("\t"));
|
|
63
|
+
assert.ok(bodyLines.length > 0, "the stub has an indented body");
|
|
64
|
+
for (const line of bodyLines) {
|
|
65
|
+
assert.ok(line.startsWith("\t"), `body line is tab-indented, not space-indented: ${line}`);
|
|
66
|
+
}
|
|
67
|
+
assert.match(stub, /app\.log\.warn\("worker not implemented", \{ jobKey: job\.jobKey \}\)/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("detectJsonIndent reuses tab/space width but clamps to JSON.stringify's 10-char cap", () => {
|
|
71
|
+
assert.equal(detectJsonIndent('{\n\t"a": 1\n}'), "\t");
|
|
72
|
+
assert.equal(detectJsonIndent('{\n "a": 1\n}'), " ");
|
|
73
|
+
assert.equal(detectJsonIndent("{}"), "\t", "no indentation falls back to a tab");
|
|
74
|
+
// JSON.stringify silently truncates a string `space` to 10 chars, so a wider space indent must
|
|
75
|
+
// be clamped rather than promising a fidelity the serializer cannot honor.
|
|
76
|
+
const wide = `{\n${" ".repeat(12)}"a": 1\n}`;
|
|
77
|
+
const indent = detectJsonIndent(wide);
|
|
78
|
+
assert.equal(indent, " ".repeat(10));
|
|
79
|
+
assert.equal(JSON.stringify({ a: 1 }, null, indent), JSON.stringify({ a: 1 }, null, wide.match(/\n( +)/)?.[1]));
|
|
80
|
+
// The 10-char cap is not space-only: an overlong *tab* indent must clamp too, or the rewrite
|
|
81
|
+
// would silently differ from an 11+-tab input (JSON.stringify truncates any string `space`).
|
|
82
|
+
const wideTabs = `{\n${"\t".repeat(12)}"a": 1\n}`;
|
|
83
|
+
const tabIndent = detectJsonIndent(wideTabs);
|
|
84
|
+
assert.equal(tabIndent, "\t".repeat(10));
|
|
85
|
+
assert.equal(JSON.stringify({ a: 1 }, null, tabIndent), JSON.stringify({ a: 1 }, null, wideTabs.match(/\n(\t+)/)?.[1]));
|
|
86
|
+
// Line-ending agnostic: the width must be detected regardless of terminator, or a space-indented
|
|
87
|
+
// Windows/old-Mac manifest would be rewritten at the tab fallback width instead of preserved.
|
|
88
|
+
assert.equal(detectJsonIndent('{\r\n "a": 1\r\n}'), " ", "CRLF space indent preserved");
|
|
89
|
+
assert.equal(detectJsonIndent('{\r "a": 1\r}'), " ", "bare-CR space indent preserved");
|
|
90
|
+
assert.equal(detectJsonIndent('{\n\r "a": 1\n\r}'), " ", "LFCR space indent preserved");
|
|
91
|
+
});
|
|
92
|
+
|
|
56
93
|
test("typed-out-only stub fills In with the open default", () => {
|
|
57
94
|
const stub = renderWorkerStub("demo.out", false, true);
|
|
58
95
|
assert.match(stub, /import type \{ WorkerOutputs \} from/);
|
|
@@ -183,6 +220,21 @@ test("scaffoldWorkers --write creates the stub and patches the manifest once", a
|
|
|
183
220
|
assert.ok(io.files["/app/nano.app.json"].endsWith("\n"), "manifest ends with newline");
|
|
184
221
|
});
|
|
185
222
|
|
|
223
|
+
test("scaffoldWorkers preserves the manifest's own indentation when wiring (nano-ide#451)", async () => {
|
|
224
|
+
// `urban gen` used to reserialize nano.app.json with a hard-coded 2-space indent, clobbering
|
|
225
|
+
// the tab-indented scaffold and breaking its own `biome check`. The patch must round-trip the
|
|
226
|
+
// file's existing indentation instead.
|
|
227
|
+
for (const indent of ["\t", " "]) {
|
|
228
|
+
const files = appFixture();
|
|
229
|
+
files["/app/nano.app.json"] = `${JSON.stringify(JSON.parse(files["/app/nano.app.json"]), null, indent)}\n`;
|
|
230
|
+
const io = memIO(files);
|
|
231
|
+
await scaffoldWorkers({ root: "/app", io, write: true });
|
|
232
|
+
const patched = io.files["/app/nano.app.json"];
|
|
233
|
+
const firstIndent = patched.match(/\n([ \t]+)\S/)?.[1];
|
|
234
|
+
assert.equal(firstIndent, indent, `indentation preserved (${JSON.stringify(indent)})`);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
186
238
|
test("scaffoldWorkers never clobbers an existing stub (keeps it), still wires it", async () => {
|
|
187
239
|
const files = appFixture();
|
|
188
240
|
files["/app/workers/t-new/worker.ts"] = "// hand-edited, do not touch\n";
|
|
@@ -87,23 +87,28 @@ export function renderWorkerStub(
|
|
|
87
87
|
else if (typedOut) generic = `<Record<string, unknown>, WorkerOutputs[${key}]>`;
|
|
88
88
|
|
|
89
89
|
const varsNote = typedIn
|
|
90
|
-
?
|
|
91
|
-
:
|
|
90
|
+
? `\t// \`job.variables\` is typed as WorkerInputs[${key}].`
|
|
91
|
+
: `\t// \`job.variables\` is an open Record<string, unknown> (no declared input type).`;
|
|
92
92
|
|
|
93
|
+
// The stub arrives lint-clean under the scaffold's own Biome config: tab-indented, and
|
|
94
|
+
// referencing both `job` and `app` (via the warn line) so `noUnusedFunctionParameters`
|
|
95
|
+
// does not fire before the author has filled in the body.
|
|
93
96
|
return (
|
|
94
97
|
`// Handler stub for the \`${taskType}\` service task, scaffolded from the model (ADR 0056).\n` +
|
|
95
98
|
`// This file is yours to edit — \`urban gen\` will never overwrite it. Implement the body\n` +
|
|
96
|
-
`// below (use \`app.data.table(...)\` for state)
|
|
99
|
+
`// below (use \`app.data.table(...)\` for state), replacing the guard warn + throw so the\n` +
|
|
100
|
+
`// "worker not implemented" warning does not survive into a working handler.\n` +
|
|
97
101
|
`import type { AppJobHandler } from "@nanobpm/urban/worker";\n` +
|
|
98
102
|
generatedImport +
|
|
99
103
|
`\n` +
|
|
100
104
|
`const handler: AppJobHandler${generic} = async (job, app) => {\n` +
|
|
101
105
|
varsNote +
|
|
102
106
|
`\n` +
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
+
`\t// Reach app state and services through the injected \`app\` API.\n` +
|
|
108
|
+
`\t// Structured logs: \`app.log.info("done", { … })\` — auto-tagged with this job's\n` +
|
|
109
|
+
`\t// { jobKey, jobType, processInstanceKey, elementId } (ADR 0061).\n` +
|
|
110
|
+
`\tapp.log.warn("worker not implemented", { jobKey: job.jobKey });\n` +
|
|
111
|
+
`\tthrow new Error(${JSON.stringify(`worker not implemented: ${taskType}`)});\n` +
|
|
107
112
|
`};\n` +
|
|
108
113
|
`\n` +
|
|
109
114
|
`export default handler;\n`
|
package/src/toolkit/scaffold.ts
CHANGED
|
@@ -14,6 +14,27 @@ import {
|
|
|
14
14
|
import { planOperationScaffold } from "./scaffold/operations.ts";
|
|
15
15
|
import { parseSpec } from "../openapi/spec.ts";
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Detect the indentation of a JSON document from its first indented line, so a rewrite can
|
|
19
|
+
* reuse it (`"\t"` or an N-space string) instead of hard-coding one style. Falls back to a tab
|
|
20
|
+
* — the scaffold's Biome default — when the file is empty/single-line or uses no indentation.
|
|
21
|
+
*
|
|
22
|
+
* Any indent — spaces or tabs — is clamped to 10 characters because `JSON.stringify` silently
|
|
23
|
+
* truncates a string `space` argument to 10; returning a wider prefix would promise a fidelity the
|
|
24
|
+
* serializer cannot honor (e.g. an 11+-tab manifest would be rewritten at a different width than
|
|
25
|
+
* its input). Tab indentation (the scaffold default) and any realistic width (2/4 spaces) are
|
|
26
|
+
* unaffected.
|
|
27
|
+
*/
|
|
28
|
+
export function detectJsonIndent(json: string): string {
|
|
29
|
+
// Anchor on any line terminator, not just `\n`, so the width is detected regardless of the
|
|
30
|
+
// manifest's line endings: LF (`\n`), CRLF (`\r\n`, where `\n` already precedes the indent),
|
|
31
|
+
// bare-CR old-Mac (`\r`), and LFCR (`\n\r`) all resolve to the indentation that follows.
|
|
32
|
+
const m = json.match(/[\r\n]([ \t]+)\S/);
|
|
33
|
+
if (!m) return "\t";
|
|
34
|
+
const indent = m[1];
|
|
35
|
+
return indent.length > 10 ? indent.slice(0, 10) : indent;
|
|
36
|
+
}
|
|
37
|
+
|
|
17
38
|
/** The manifest fields the scaffolder reads. */
|
|
18
39
|
interface ScaffoldManifest {
|
|
19
40
|
types?: Record<string, unknown>;
|
|
@@ -61,7 +82,11 @@ export async function scaffoldWorkers(opts: ScaffoldOptions): Promise<ScaffoldRu
|
|
|
61
82
|
const manifestFile = opts.manifestFile ?? "nano.app.json";
|
|
62
83
|
const manifestPath = joinPath(root, manifestFile);
|
|
63
84
|
|
|
64
|
-
const
|
|
85
|
+
const manifestRaw = await io.readText(manifestPath);
|
|
86
|
+
const manifest: ScaffoldManifest = JSON.parse(manifestRaw);
|
|
87
|
+
// Preserve the manifest's own indentation when we patch it back (default tab, matching the
|
|
88
|
+
// scaffold's Biome config), so wiring a worker never reformats the file out from under lint.
|
|
89
|
+
const manifestIndent = detectJsonIndent(manifestRaw);
|
|
65
90
|
const models = await readModels(root, io, manifest);
|
|
66
91
|
const declaredTypeIds = Object.keys(manifest.types ?? {});
|
|
67
92
|
|
|
@@ -105,7 +130,7 @@ export async function scaffoldWorkers(opts: ScaffoldOptions): Promise<ScaffoldRu
|
|
|
105
130
|
...manifest,
|
|
106
131
|
workers: [...(manifest.workers ?? []), ...wired],
|
|
107
132
|
};
|
|
108
|
-
await io.writeText(manifestPath, `${JSON.stringify(withWorkers, null,
|
|
133
|
+
await io.writeText(manifestPath, `${JSON.stringify(withWorkers, null, manifestIndent)}\n`);
|
|
109
134
|
manifestPatched = true;
|
|
110
135
|
}
|
|
111
136
|
|