@akanjs/devkit 2.3.11-rc.2 → 2.3.11-rc.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/akanApp/akanApp.host.ts +38 -0
- package/applicationTestPreload.ts +7 -2
- package/lint/no-deep-internal-import.grit +10 -0
- package/lint/no-redeclare-predefined-endpoint.grit +26 -0
- package/package.json +2 -2
- package/qualityScanner.ts +2 -2
- package/workflow/executor.ts +20 -0
- package/workflow/source.test.ts +114 -1
- package/workflow/source.ts +131 -0
- package/workflow/types.ts +10 -0
package/akanApp/akanApp.host.ts
CHANGED
|
@@ -11,6 +11,7 @@ const BACKEND_RESTART_DEBOUNCE_MS = 120;
|
|
|
11
11
|
const BACKEND_GRACEFUL_TIMEOUT_MS = 3000;
|
|
12
12
|
const BACKEND_RECOVERY_BASE_DELAY_MS = 1_000;
|
|
13
13
|
const BACKEND_RECOVERY_MAX_DELAY_MS = 30_000;
|
|
14
|
+
const BACKEND_STDERR_TAIL_LIMIT = 40;
|
|
14
15
|
const BUILDER_READY_TIMEOUT_MS = 150000;
|
|
15
16
|
const BUILDER_START_MAX_ATTEMPTS = 3;
|
|
16
17
|
const SOURCE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
@@ -270,6 +271,7 @@ export class AkanAppHost {
|
|
|
270
271
|
#pendingRestartReason: BackendRestartReason | null = null;
|
|
271
272
|
#backendStartStatus: { generation?: number; files: string[] } | null = null;
|
|
272
273
|
#backendBuildStatusGeneration = 0;
|
|
274
|
+
#backendStderrTail: string[] = [];
|
|
273
275
|
#lastGoodFrontend: LastGoodFrontendState = {};
|
|
274
276
|
#buildStatusByPhase = new Map<BuildPhase, DevBuildStatus>();
|
|
275
277
|
#pendingBuildStatusReplay: DevBuildStatus[] = [];
|
|
@@ -321,6 +323,7 @@ export class AkanAppHost {
|
|
|
321
323
|
this.#backendStartStatus = startStatus;
|
|
322
324
|
this.#setBackendLifecycleState("starting");
|
|
323
325
|
this.#backendReady = false;
|
|
326
|
+
this.#backendStderrTail = [];
|
|
324
327
|
const backend = Bun.spawn(["bun", `apps/${this.app.name}/main.ts`], {
|
|
325
328
|
cwd: this.app.workspace.workspaceRoot,
|
|
326
329
|
stdio: this.withInk ? ["ignore", "pipe", "pipe"] : ["inherit", "inherit", "inherit"],
|
|
@@ -351,6 +354,38 @@ export class AkanAppHost {
|
|
|
351
354
|
});
|
|
352
355
|
this.#backend = backend;
|
|
353
356
|
this.logger.verbose(`backend spawned pid=${backend.pid}`);
|
|
357
|
+
if (this.withInk) {
|
|
358
|
+
// Ink mode pipes backend stdio to keep the TUI clean; drain the pipes and surface
|
|
359
|
+
// them through the logger so runtime errors are not silently swallowed.
|
|
360
|
+
void this.#forwardBackendStream(backend.stderr as unknown as ReadableStream<Uint8Array> | undefined, "stderr");
|
|
361
|
+
void this.#forwardBackendStream(backend.stdout as unknown as ReadableStream<Uint8Array> | undefined, "stdout");
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
#recordBackendStderr(chunk: string) {
|
|
365
|
+
const lines = chunk.split(/\r?\n/).filter((line) => line.length > 0);
|
|
366
|
+
if (lines.length === 0) return;
|
|
367
|
+
this.#backendStderrTail.push(...lines);
|
|
368
|
+
if (this.#backendStderrTail.length > BACKEND_STDERR_TAIL_LIMIT) {
|
|
369
|
+
this.#backendStderrTail.splice(0, this.#backendStderrTail.length - BACKEND_STDERR_TAIL_LIMIT);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
async #forwardBackendStream(stream: ReadableStream<Uint8Array> | undefined | null, kind: "stdout" | "stderr") {
|
|
373
|
+
if (!stream) return;
|
|
374
|
+
const decoder = new TextDecoder();
|
|
375
|
+
try {
|
|
376
|
+
for await (const chunk of stream) {
|
|
377
|
+
const text = decoder.decode(chunk, { stream: true });
|
|
378
|
+
if (!text.trim()) continue;
|
|
379
|
+
if (kind === "stderr") {
|
|
380
|
+
this.#recordBackendStderr(text);
|
|
381
|
+
this.logger.warn(`[backend] ${text.trimEnd()}`);
|
|
382
|
+
} else {
|
|
383
|
+
this.logger.verbose(`[backend] ${text.trimEnd()}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
} catch {
|
|
387
|
+
// The stream closes when the backend exits; nothing further to surface here.
|
|
388
|
+
}
|
|
354
389
|
}
|
|
355
390
|
#nextBackendBuildStatusGeneration(generation?: number): number {
|
|
356
391
|
if (typeof generation === "number") {
|
|
@@ -482,6 +517,9 @@ export class AkanAppHost {
|
|
|
482
517
|
this.logger.warn(
|
|
483
518
|
`[backend-recovery] backend exited unexpectedly (${reason}); restarting in ${delay}ms (attempt ${this.#backendRecoveryAttempts})`,
|
|
484
519
|
);
|
|
520
|
+
if (this.#backendStderrTail.length > 0) {
|
|
521
|
+
this.logger.warn(`[backend-recovery] recent backend stderr:\n${this.#backendStderrTail.join("\n")}`);
|
|
522
|
+
}
|
|
485
523
|
this.#backendRecoveryTimer = setTimeout(() => {
|
|
486
524
|
this.#backendRecoveryTimer = null;
|
|
487
525
|
if (this.#backend) return;
|
|
@@ -32,9 +32,14 @@ export async function resolveSignalTestPreloadPath(target: SignalTestPreloadTarg
|
|
|
32
32
|
path.resolve(import.meta.dir, "../../akanjs", SIGNAL_TEST_PRELOAD_PATH),
|
|
33
33
|
);
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
const uniqueCandidates = [...new Set(candidates)];
|
|
36
|
+
for (const candidate of uniqueCandidates) {
|
|
36
37
|
if (await Bun.file(candidate).exists()) return candidate;
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
throw new Error(
|
|
40
|
+
throw new Error(
|
|
41
|
+
`Failed to locate ${SIGNAL_TEST_PRELOAD_PATH} from ${target.cwdPath}.\nProbed paths:\n${uniqueCandidates
|
|
42
|
+
.map((candidate) => ` - ${candidate}`)
|
|
43
|
+
.join("\n")}`,
|
|
44
|
+
);
|
|
40
45
|
}
|
|
@@ -21,5 +21,15 @@ or {
|
|
|
21
21
|
span = $source,
|
|
22
22
|
message = "Module files should not import from two or more parent directories."
|
|
23
23
|
)
|
|
24
|
+
},
|
|
25
|
+
JsModuleSource() as $source where {
|
|
26
|
+
$source <: within JsImport(),
|
|
27
|
+
not $filename <: r".*\.(?:test|spec)\.tsx?",
|
|
28
|
+
$filename <: r".*/lib/.+\.tsx",
|
|
29
|
+
$source <: r"\"\.\./.*\"",
|
|
30
|
+
register_diagnostic(
|
|
31
|
+
span = $source,
|
|
32
|
+
message = "Module UI (.tsx) files under lib must not use internal relative imports like ../cnst. Import from the package client/server entrypoint instead."
|
|
33
|
+
)
|
|
24
34
|
}
|
|
25
35
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// Every model already gets auto-generated CRUD endpoints in its slice fetch
|
|
5
|
+
// contract (see pkgs/akanjs/fetch/fetchType/sliceFetch.type.ts):
|
|
6
|
+
// <refName>, light<Model>, create<Model>, update<Model>, remove<Model>.
|
|
7
|
+
// Re-declaring one of them inside the `endpoint(...)` block of a
|
|
8
|
+
// `<model>.signal.ts` file shadows the framework contract and can pass
|
|
9
|
+
// sync/typecheck/build while failing at runtime, so flag it here.
|
|
10
|
+
`$key: $value` as $prop where {
|
|
11
|
+
$prop <: within `class $className extends $base {}` where {
|
|
12
|
+
$base <: contains `endpoint`,
|
|
13
|
+
$className <: r"([A-Z][a-zA-Z0-9]*)Endpoint"($Cap)
|
|
14
|
+
},
|
|
15
|
+
$filename <: r".*/([a-z][a-zA-Z0-9]*)\.signal\.ts"($model),
|
|
16
|
+
or {
|
|
17
|
+
$key <: $model,
|
|
18
|
+
$key <: r"(?:light|create|update|remove)([A-Z][a-zA-Z0-9]*)"($keyCap) where {
|
|
19
|
+
$keyCap <: $Cap
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
register_diagnostic(
|
|
23
|
+
span = $key,
|
|
24
|
+
message = "This endpoint name collides with an auto-generated CRUD endpoint (<model>, light/create/update/remove<Model>). Rename it or move the logic into the service; do not re-declare predefined endpoints."
|
|
25
|
+
)
|
|
26
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "2.3.11-rc.
|
|
3
|
+
"version": "2.3.11-rc.4",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"@langchain/openai": "^1.4.6",
|
|
33
33
|
"@tailwindcss/node": "^4.3.0",
|
|
34
34
|
"@trapezedev/project": "^7.1.4",
|
|
35
|
-
"akanjs": "2.3.11-rc.
|
|
35
|
+
"akanjs": "2.3.11-rc.4",
|
|
36
36
|
"chalk": "^5.6.2",
|
|
37
37
|
"commander": "^14.0.3",
|
|
38
38
|
"daisyui": "5.5.23",
|
package/qualityScanner.ts
CHANGED
|
@@ -549,8 +549,8 @@ function getConventionDescription(suffix: (typeof CONVENTION_SUFFIXES)[number],
|
|
|
549
549
|
|
|
550
550
|
function getLibRootFile(file: string) {
|
|
551
551
|
const segments = file.split("/");
|
|
552
|
-
if (segments[0] === "libs" && segments.length === 4 && segments[2] === "lib")
|
|
553
|
-
|
|
552
|
+
if ((segments[0] === "libs" || segments[0] === "apps") && segments.length === 4 && segments[2] === "lib")
|
|
553
|
+
return segments[3];
|
|
554
554
|
return null;
|
|
555
555
|
}
|
|
556
556
|
|
package/workflow/executor.ts
CHANGED
|
@@ -403,6 +403,8 @@ export const createWorkflowStepRegistry = ({
|
|
|
403
403
|
createUi,
|
|
404
404
|
addField,
|
|
405
405
|
addEnumField,
|
|
406
|
+
addMutation,
|
|
407
|
+
addSlice,
|
|
406
408
|
}: WorkflowPrimitiveOperations): WorkflowStepRegistry => {
|
|
407
409
|
const inspect = async () => undefined;
|
|
408
410
|
const commandOnly = async () => undefined;
|
|
@@ -483,6 +485,24 @@ export const createWorkflowStepRegistry = ({
|
|
|
483
485
|
),
|
|
484
486
|
[workflowStepKey("add-enum-field", "update-dictionary")]: inspect,
|
|
485
487
|
[workflowStepKey("add-enum-field", "update-option")]: inspect,
|
|
488
|
+
[workflowStepKey("add-mutation", "update-service")]: async (_step, plan) =>
|
|
489
|
+
primitiveReportToWorkflowStepResult(
|
|
490
|
+
await addMutation({
|
|
491
|
+
app: workflowStringInput(plan.inputs.app),
|
|
492
|
+
module: workflowStringInput(plan.inputs.module),
|
|
493
|
+
mutation: workflowStringInput(plan.inputs.mutation),
|
|
494
|
+
}),
|
|
495
|
+
),
|
|
496
|
+
[workflowStepKey("add-mutation", "update-signal")]: inspect,
|
|
497
|
+
[workflowStepKey("add-slice", "update-service-query")]: async (_step, plan) =>
|
|
498
|
+
primitiveReportToWorkflowStepResult(
|
|
499
|
+
await addSlice({
|
|
500
|
+
app: workflowStringInput(plan.inputs.app),
|
|
501
|
+
module: workflowStringInput(plan.inputs.module),
|
|
502
|
+
slice: workflowStringInput(plan.inputs.slice),
|
|
503
|
+
}),
|
|
504
|
+
),
|
|
505
|
+
[workflowStepKey("add-slice", "update-signal-slice")]: inspect,
|
|
486
506
|
};
|
|
487
507
|
};
|
|
488
508
|
|
package/workflow/source.test.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
hasClassMethod,
|
|
4
|
+
hasSignalFactoryEntry,
|
|
5
|
+
hasSourceParseErrors,
|
|
6
|
+
insertClassMethod,
|
|
7
|
+
insertSignalFactoryEntry,
|
|
8
|
+
inspectDictionaryStructure,
|
|
9
|
+
} from "./source";
|
|
3
10
|
|
|
4
11
|
describe("inspectDictionaryStructure", () => {
|
|
5
12
|
test("preserves protected dictionary chain order around the model object", () => {
|
|
@@ -60,3 +67,109 @@ export const dictionary = modelDictionary(["en", "ko"])
|
|
|
60
67
|
expect(structure.chainMethods).toEqual(["modelDictionary", "slice", "model", "translate", "error"]);
|
|
61
68
|
});
|
|
62
69
|
});
|
|
70
|
+
|
|
71
|
+
describe("insertClassMethod", () => {
|
|
72
|
+
test("inserts a method into an empty service class body", () => {
|
|
73
|
+
const content = `import { serve } from "akanjs/service";
|
|
74
|
+
import * as db from "../db";
|
|
75
|
+
export class BannerService extends serve(db.banner, () => ({})) {}
|
|
76
|
+
`;
|
|
77
|
+
const next = insertClassMethod(content, "BannerService", " async archive() {\n return true;\n }");
|
|
78
|
+
expect(next).not.toBeNull();
|
|
79
|
+
expect(hasSourceParseErrors(next as string, "service.ts")).toBe(false);
|
|
80
|
+
expect(hasClassMethod(next as string, "BannerService", "archive")).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("inserts a method into a populated service class body", () => {
|
|
84
|
+
const content = `import { serve } from "akanjs/service";
|
|
85
|
+
import * as db from "../db";
|
|
86
|
+
export class NotificationService extends serve(db.notification, () => ({})) {
|
|
87
|
+
async subscribe(token: string) {
|
|
88
|
+
return token;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
`;
|
|
92
|
+
const next = insertClassMethod(content, "NotificationService", " async archive() {\n return true;\n }");
|
|
93
|
+
expect(next).not.toBeNull();
|
|
94
|
+
expect(hasSourceParseErrors(next as string, "service.ts")).toBe(false);
|
|
95
|
+
expect(hasClassMethod(next as string, "NotificationService", "archive")).toBe(true);
|
|
96
|
+
expect(hasClassMethod(next as string, "NotificationService", "subscribe")).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("returns null when the class is not found", () => {
|
|
100
|
+
expect(insertClassMethod("export class Other {}", "MissingService", " x() {}")).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("insertSignalFactoryEntry", () => {
|
|
105
|
+
const mutationEntry =
|
|
106
|
+
"archive: mutation(Boolean)\n .exec(async function () {\n return await this.bannerService.archive();\n }),";
|
|
107
|
+
|
|
108
|
+
test("adds mutation param when the endpoint factory has no params", () => {
|
|
109
|
+
const content = `import { endpoint } from "akanjs/signal";
|
|
110
|
+
import * as srv from "../srv";
|
|
111
|
+
export class BannerEndpoint extends endpoint(srv.banner, () => ({})) {}
|
|
112
|
+
`;
|
|
113
|
+
const next = insertSignalFactoryEntry(content, "BannerEndpoint", "archive", mutationEntry, {
|
|
114
|
+
mode: "destructure",
|
|
115
|
+
name: "mutation",
|
|
116
|
+
});
|
|
117
|
+
expect(next).not.toBeNull();
|
|
118
|
+
expect(hasSourceParseErrors(next as string, "signal.ts")).toBe(false);
|
|
119
|
+
expect(hasSignalFactoryEntry(next as string, "BannerEndpoint", "archive")).toBe(true);
|
|
120
|
+
expect(next as string).toContain("{ mutation }");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("extends an existing destructured param without dropping siblings", () => {
|
|
124
|
+
const content = `import { endpoint } from "akanjs/signal";
|
|
125
|
+
import * as srv from "../srv";
|
|
126
|
+
export class BannerEndpoint extends endpoint(srv.banner, ({ pubsub, query }) => ({
|
|
127
|
+
existing: query(String).exec(async function () {
|
|
128
|
+
return "x";
|
|
129
|
+
}),
|
|
130
|
+
})) {}
|
|
131
|
+
`;
|
|
132
|
+
const next = insertSignalFactoryEntry(content, "BannerEndpoint", "archive", mutationEntry, {
|
|
133
|
+
mode: "destructure",
|
|
134
|
+
name: "mutation",
|
|
135
|
+
});
|
|
136
|
+
expect(next).not.toBeNull();
|
|
137
|
+
expect(hasSourceParseErrors(next as string, "signal.ts")).toBe(false);
|
|
138
|
+
expect(next as string).toContain("{ pubsub, query, mutation }");
|
|
139
|
+
expect(hasSignalFactoryEntry(next as string, "BannerEndpoint", "archive")).toBe(true);
|
|
140
|
+
expect(hasSignalFactoryEntry(next as string, "BannerEndpoint", "existing")).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("adds the init param to a slice factory and inserts the slice entry", () => {
|
|
144
|
+
const content = `import { slice, Admin, Public } from "akanjs/signal";
|
|
145
|
+
import * as srv from "../srv";
|
|
146
|
+
export class BannerSlice extends slice(srv.banner, { guards: { root: Admin, get: Public, cru: Admin } }, () => ({})) {}
|
|
147
|
+
`;
|
|
148
|
+
const sliceEntry =
|
|
149
|
+
"inPublic: init()\n .exec(function () {\n return this.bannerService.queryInPublic();\n }),";
|
|
150
|
+
const next = insertSignalFactoryEntry(content, "BannerSlice", "inPublic", sliceEntry, {
|
|
151
|
+
mode: "positional",
|
|
152
|
+
name: "init",
|
|
153
|
+
});
|
|
154
|
+
expect(next).not.toBeNull();
|
|
155
|
+
expect(hasSourceParseErrors(next as string, "signal.ts")).toBe(false);
|
|
156
|
+
expect(next as string).toContain("(init) =>");
|
|
157
|
+
expect(hasSignalFactoryEntry(next as string, "BannerSlice", "inPublic")).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("is idempotent when the entry already exists", () => {
|
|
161
|
+
const content = `import { endpoint } from "akanjs/signal";
|
|
162
|
+
import * as srv from "../srv";
|
|
163
|
+
export class BannerEndpoint extends endpoint(srv.banner, ({ mutation }) => ({
|
|
164
|
+
archive: mutation(Boolean).exec(async function () {
|
|
165
|
+
return true;
|
|
166
|
+
}),
|
|
167
|
+
})) {}
|
|
168
|
+
`;
|
|
169
|
+
const next = insertSignalFactoryEntry(content, "BannerEndpoint", "archive", mutationEntry, {
|
|
170
|
+
mode: "destructure",
|
|
171
|
+
name: "mutation",
|
|
172
|
+
});
|
|
173
|
+
expect(next).toBe(content);
|
|
174
|
+
});
|
|
175
|
+
});
|
package/workflow/source.ts
CHANGED
|
@@ -862,3 +862,134 @@ export const parseValues = (value: string | null) =>
|
|
|
862
862
|
?.split(",")
|
|
863
863
|
.map((item) => item.trim())
|
|
864
864
|
.filter(Boolean) ?? [];
|
|
865
|
+
|
|
866
|
+
// ---- Service / signal insertion (add-mutation, add-slice) ----
|
|
867
|
+
|
|
868
|
+
export const hasSourceParseErrors = (content: string, fileName = "source.ts") =>
|
|
869
|
+
hasParseDiagnostics(sourceFileFor(fileName, content));
|
|
870
|
+
|
|
871
|
+
const findClassDeclaration = (source: ts.SourceFile, className: string): ts.ClassDeclaration | null => {
|
|
872
|
+
let found: ts.ClassDeclaration | null = null;
|
|
873
|
+
const visit = (node: ts.Node) => {
|
|
874
|
+
if (found) return;
|
|
875
|
+
if (ts.isClassDeclaration(node) && node.name?.text === className) {
|
|
876
|
+
found = node;
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
ts.forEachChild(node, visit);
|
|
880
|
+
};
|
|
881
|
+
ts.forEachChild(source, visit);
|
|
882
|
+
return found;
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
const firstHeritageCall = (node: ts.ClassDeclaration): ts.CallExpression | null => {
|
|
886
|
+
const expression = (node.heritageClauses?.flatMap((clause) => [...clause.types]) ?? [])[0]?.expression;
|
|
887
|
+
return expression && ts.isCallExpression(expression) ? expression : null;
|
|
888
|
+
};
|
|
889
|
+
|
|
890
|
+
const factoryArrowOf = (call: ts.CallExpression): ts.ArrowFunction | ts.FunctionExpression | null => {
|
|
891
|
+
const arg = call.arguments.find((argument) => ts.isArrowFunction(argument) || ts.isFunctionExpression(argument));
|
|
892
|
+
return arg && (ts.isArrowFunction(arg) || ts.isFunctionExpression(arg)) ? arg : null;
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
export const hasClassMethod = (content: string, className: string, methodName: string) => {
|
|
896
|
+
const node = findClassDeclaration(sourceFileFor("service.ts", content), className);
|
|
897
|
+
return Boolean(
|
|
898
|
+
node?.members.some((member) => ts.isMethodDeclaration(member) && nodeName(member.name) === methodName),
|
|
899
|
+
);
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
export const insertClassMethod = (content: string, className: string, methodBlock: string): string | null => {
|
|
903
|
+
const source = sourceFileFor("service.ts", content);
|
|
904
|
+
const node = findClassDeclaration(source, className);
|
|
905
|
+
if (!node) return null;
|
|
906
|
+
const closeBrace = node.getEnd() - 1;
|
|
907
|
+
if (content[closeBrace] !== "}") return null;
|
|
908
|
+
const lead = content.slice(0, closeBrace).endsWith("\n") ? "" : "\n";
|
|
909
|
+
return spliceText(content, closeBrace, closeBrace, `${lead}${methodBlock}\n`);
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
export interface FactoryParamPlan {
|
|
913
|
+
mode: "destructure" | "positional";
|
|
914
|
+
name: string;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const arrowParamInnerRegion = (
|
|
918
|
+
content: string,
|
|
919
|
+
source: ts.SourceFile,
|
|
920
|
+
arrow: ts.ArrowFunction | ts.FunctionExpression,
|
|
921
|
+
) => {
|
|
922
|
+
if (arrow.parameters.length > 0) {
|
|
923
|
+
return {
|
|
924
|
+
start: arrow.parameters[0].getStart(source),
|
|
925
|
+
end: arrow.parameters[arrow.parameters.length - 1].getEnd(),
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
const open = content.indexOf("(", arrow.getStart(source));
|
|
929
|
+
if (open < 0) return null;
|
|
930
|
+
const close = content.indexOf(")", open);
|
|
931
|
+
if (close < 0) return null;
|
|
932
|
+
return { start: open + 1, end: close };
|
|
933
|
+
};
|
|
934
|
+
|
|
935
|
+
const factoryParamEdit = (
|
|
936
|
+
content: string,
|
|
937
|
+
source: ts.SourceFile,
|
|
938
|
+
arrow: ts.ArrowFunction | ts.FunctionExpression,
|
|
939
|
+
plan: FactoryParamPlan,
|
|
940
|
+
): { start: number; end: number; text: string } | "unchanged" | null => {
|
|
941
|
+
if (arrow.parameters.length > 1) return null;
|
|
942
|
+
const region = arrowParamInnerRegion(content, source, arrow);
|
|
943
|
+
if (!region) return null;
|
|
944
|
+
if (arrow.parameters.length === 0) {
|
|
945
|
+
return { ...region, text: plan.mode === "destructure" ? `{ ${plan.name} }` : plan.name };
|
|
946
|
+
}
|
|
947
|
+
const param = arrow.parameters[0];
|
|
948
|
+
if (plan.mode === "positional") return nodeName(param.name) === plan.name ? "unchanged" : null;
|
|
949
|
+
if (!ts.isObjectBindingPattern(param.name)) return null;
|
|
950
|
+
const names = param.name.elements.map((element) => (ts.isIdentifier(element.name) ? element.name.text : null));
|
|
951
|
+
if (names.some((name) => name === null)) return null;
|
|
952
|
+
if (names.includes(plan.name)) return "unchanged";
|
|
953
|
+
return {
|
|
954
|
+
start: param.getStart(source),
|
|
955
|
+
end: param.getEnd(),
|
|
956
|
+
text: `{ ${[...(names as string[]), plan.name].join(", ")} }`,
|
|
957
|
+
};
|
|
958
|
+
};
|
|
959
|
+
|
|
960
|
+
const factoryObjectOf = (source: ts.SourceFile, className: string) => {
|
|
961
|
+
const node = findClassDeclaration(source, className);
|
|
962
|
+
const call = node ? firstHeritageCall(node) : null;
|
|
963
|
+
const arrow = call ? factoryArrowOf(call) : null;
|
|
964
|
+
const object = arrow ? firstObjectReturnedByArrow(arrow) : null;
|
|
965
|
+
return arrow && object ? { arrow, object } : null;
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
export const hasSignalFactoryEntry = (content: string, className: string, entryName: string) => {
|
|
969
|
+
const located = factoryObjectOf(sourceFileFor("signal.ts", content), className);
|
|
970
|
+
return Boolean(located?.object.properties.some((property) => propertyName(property) === entryName));
|
|
971
|
+
};
|
|
972
|
+
|
|
973
|
+
export const insertSignalFactoryEntry = (
|
|
974
|
+
content: string,
|
|
975
|
+
className: string,
|
|
976
|
+
entryName: string,
|
|
977
|
+
entryLine: string,
|
|
978
|
+
param: FactoryParamPlan,
|
|
979
|
+
): string | null => {
|
|
980
|
+
const source = sourceFileFor("signal.ts", content);
|
|
981
|
+
const located = factoryObjectOf(source, className);
|
|
982
|
+
if (!located) return null;
|
|
983
|
+
const locator = locatedObject(source, located.object);
|
|
984
|
+
if (locator.fields.some((field) => field.name === entryName)) return content;
|
|
985
|
+
// Compute the param edit first (its offsets precede the object), but apply it last so the
|
|
986
|
+
// object splice (at a higher offset) does not invalidate the param region positions.
|
|
987
|
+
const paramEdit = factoryParamEdit(content, source, located.arrow, param);
|
|
988
|
+
if (paramEdit === null) return null;
|
|
989
|
+
const withEntry = insertOrderedFieldLine(content, locator, entryName, entryLine, {
|
|
990
|
+
fieldIndent: " ",
|
|
991
|
+
closingIndent: "",
|
|
992
|
+
});
|
|
993
|
+
if (withEntry === content) return null;
|
|
994
|
+
return paramEdit === "unchanged" ? withEntry : spliceText(withEntry, paramEdit.start, paramEdit.end, paramEdit.text);
|
|
995
|
+
};
|
package/workflow/types.ts
CHANGED
|
@@ -47,6 +47,14 @@ export interface AddEnumFieldInput extends PrimitiveTargetInput {
|
|
|
47
47
|
includeInLight?: boolean | null;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
export interface AddMutationInput extends PrimitiveTargetInput {
|
|
51
|
+
mutation: string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface AddSliceInput extends PrimitiveTargetInput {
|
|
55
|
+
slice: string | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
50
58
|
export interface WorkflowInputSpec {
|
|
51
59
|
type: WorkflowInputType;
|
|
52
60
|
required?: boolean;
|
|
@@ -472,4 +480,6 @@ export interface WorkflowPrimitiveOperations {
|
|
|
472
480
|
createUi: (input: PrimitiveTargetInput & { surface: UiSurface }) => Promise<PrimitiveWriteReport>;
|
|
473
481
|
addField: (input: AddFieldInput) => Promise<PrimitiveWriteReport>;
|
|
474
482
|
addEnumField: (input: AddEnumFieldInput) => Promise<PrimitiveWriteReport>;
|
|
483
|
+
addMutation: (input: AddMutationInput) => Promise<PrimitiveWriteReport>;
|
|
484
|
+
addSlice: (input: AddSliceInput) => Promise<PrimitiveWriteReport>;
|
|
475
485
|
}
|