@akanjs/devkit 3.1.1-rc.14 → 3.1.1-rc.16
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/applicationBuildRunner.test.ts +17 -0
- package/applicationBuildRunner.ts +2 -1
- package/biome.base.json +4 -1
- package/commentDensityScanner.test.ts +121 -0
- package/commentDensityScanner.ts +101 -0
- package/componentSizeScanner.test.ts +36 -0
- package/componentSizeScanner.ts +52 -0
- package/componentWriteChainScanner.test.ts +137 -0
- package/componentWriteChainScanner.ts +104 -0
- package/deprecatedCommentScanner.test.ts +54 -0
- package/deprecatedCommentScanner.ts +30 -0
- package/endpointReach.test.ts +58 -0
- package/endpointReach.ts +80 -0
- package/index.ts +1 -0
- package/kindBranchScanner.test.ts +89 -0
- package/kindBranchScanner.ts +158 -0
- package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
- package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
- package/lint/no-oversized-list-limit.grit +73 -0
- package/maintainabilityBaseline.test.ts +280 -0
- package/maintainabilityBaseline.ts +220 -0
- package/maintainabilityContract.test.ts +846 -0
- package/maintainabilityContract.ts +404 -0
- package/maintainabilityExceptionScanner.test.ts +90 -0
- package/maintainabilityExceptionScanner.ts +85 -0
- package/maintainabilityLimits.ts +60 -0
- package/maintainabilityReport.test.ts +130 -0
- package/maintainabilityReport.ts +142 -0
- package/package.json +2 -2
- package/qualityScanner.test.ts +75 -0
- package/qualityScanner.ts +17 -1
- package/serviceLoopCallScanner.test.ts +56 -0
- package/serviceLoopCallScanner.ts +122 -0
- package/sourceComments.test.ts +45 -0
- package/sourceComments.ts +70 -0
- package/storeAccessScanner.test.ts +84 -0
- package/storeAccessScanner.ts +71 -0
- package/storeSource.ts +159 -0
- package/storeWriteScanner.test.ts +292 -0
- package/storeWriteScanner.ts +50 -0
- package/testOnlyExportScanner.test.ts +83 -0
- package/testOnlyExportScanner.ts +124 -0
- package/workflow/artifacts.ts +6 -1
- package/workflow/executor.ts +22 -7
- package/workflow/guardCatalog.test.ts +59 -0
- package/workflow/guardCatalog.ts +108 -0
- package/workflow/index.ts +4 -0
- package/workflow/interactionScaffold.ts +122 -0
- package/workflow/mutationScaffold.ts +519 -0
- package/workflow/mutationSource.test.ts +108 -0
- package/workflow/mutationSource.ts +219 -0
- package/workflow/plan.ts +54 -0
- package/workflow/render.ts +15 -1
- package/workflow/source.test.ts +130 -0
- package/workflow/source.ts +122 -14
- package/workflow/types.ts +17 -0
- package/workspaceLayout.ts +2 -0
- package/writePath.test.ts +109 -0
- package/writePath.ts +219 -0
- package/writePlacementScanner.test.ts +77 -0
- package/writePlacementScanner.ts +50 -0
- package/writingActionIndex.test.ts +99 -0
- package/writingActionIndex.ts +63 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { lowerlize } from "akanjs/common";
|
|
2
|
+
import { bilingualLabelForField, moduleComponentName, titleize } from "./source";
|
|
3
|
+
|
|
4
|
+
export interface MutationCall {
|
|
5
|
+
ref: string;
|
|
6
|
+
method: string;
|
|
7
|
+
field: string | null;
|
|
8
|
+
args: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface MutationActingUser {
|
|
12
|
+
argName: string;
|
|
13
|
+
param: string;
|
|
14
|
+
value: string;
|
|
15
|
+
serviceParam: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface MutationSourceSpec {
|
|
19
|
+
module: string;
|
|
20
|
+
mutation: string;
|
|
21
|
+
guards: readonly string[];
|
|
22
|
+
acting: MutationActingUser | null;
|
|
23
|
+
withCalls: readonly MutationCall[];
|
|
24
|
+
afterCalls: readonly MutationCall[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class MutationSource {
|
|
28
|
+
static readonly #irregularParticiples = new Map([
|
|
29
|
+
["admit", "admitted"],
|
|
30
|
+
["bind", "bound"],
|
|
31
|
+
["build", "built"],
|
|
32
|
+
["choose", "chosen"],
|
|
33
|
+
["commit", "committed"],
|
|
34
|
+
["control", "controlled"],
|
|
35
|
+
["cut", "cut"],
|
|
36
|
+
["find", "found"],
|
|
37
|
+
["freeze", "frozen"],
|
|
38
|
+
["get", "got"],
|
|
39
|
+
["give", "given"],
|
|
40
|
+
["hide", "hidden"],
|
|
41
|
+
["hold", "held"],
|
|
42
|
+
["keep", "kept"],
|
|
43
|
+
["lead", "led"],
|
|
44
|
+
["leave", "left"],
|
|
45
|
+
["lose", "lost"],
|
|
46
|
+
["make", "made"],
|
|
47
|
+
["omit", "omitted"],
|
|
48
|
+
["pay", "paid"],
|
|
49
|
+
["permit", "permitted"],
|
|
50
|
+
["put", "put"],
|
|
51
|
+
["read", "read"],
|
|
52
|
+
["run", "run"],
|
|
53
|
+
["sell", "sold"],
|
|
54
|
+
["send", "sent"],
|
|
55
|
+
["set", "set"],
|
|
56
|
+
["show", "shown"],
|
|
57
|
+
["shut", "shut"],
|
|
58
|
+
["split", "split"],
|
|
59
|
+
["submit", "submitted"],
|
|
60
|
+
["take", "taken"],
|
|
61
|
+
["transfer", "transferred"],
|
|
62
|
+
["win", "won"],
|
|
63
|
+
["write", "written"],
|
|
64
|
+
]);
|
|
65
|
+
readonly spec: MutationSourceSpec;
|
|
66
|
+
readonly modelClassName: string;
|
|
67
|
+
readonly model: string;
|
|
68
|
+
readonly idParam: string;
|
|
69
|
+
readonly chainMethod: string;
|
|
70
|
+
|
|
71
|
+
constructor(spec: MutationSourceSpec) {
|
|
72
|
+
this.spec = spec;
|
|
73
|
+
this.modelClassName = moduleComponentName(spec.module);
|
|
74
|
+
this.model = lowerlize(this.modelClassName);
|
|
75
|
+
this.idParam = `${this.model}Id`;
|
|
76
|
+
this.chainMethod = MutationSource.chainMethodOf(spec.mutation, this.modelClassName);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
static chainMethodOf(mutation: string, modelClassName: string) {
|
|
80
|
+
const dropped = mutation.replace(modelClassName, "");
|
|
81
|
+
return dropped && dropped !== mutation ? lowerlize(dropped) : mutation;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
static participleOf(verb: string) {
|
|
85
|
+
const irregular = MutationSource.#irregularParticiples.get(verb);
|
|
86
|
+
if (irregular) return irregular;
|
|
87
|
+
if (verb.endsWith("e")) return `${verb}d`;
|
|
88
|
+
if (/[^aeiou]y$/.test(verb)) return `${verb.slice(0, -1)}ied`;
|
|
89
|
+
if (/^[^aeiou]*[aeiou][^aeiouwxy]$/.test(verb)) return `${verb}${verb.at(-1)}ed`;
|
|
90
|
+
return `${verb}ed`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
get isPublic() {
|
|
94
|
+
return this.spec.guards.includes("Public");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
get #verb() {
|
|
98
|
+
return this.chainMethod.replace(/[A-Z].*$/s, "");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
get #noun() {
|
|
102
|
+
return titleize(this.model).toLowerCase();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
get chainMethodBlock() {
|
|
106
|
+
return [
|
|
107
|
+
` // any → ${MutationSource.participleOf(this.#verb)}.`,
|
|
108
|
+
` ${this.chainMethod}() {`,
|
|
109
|
+
" return this;",
|
|
110
|
+
" }",
|
|
111
|
+
].join("\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
get serviceMethod() {
|
|
115
|
+
return this.chainMethod;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
get serviceMethodBlock() {
|
|
119
|
+
const { acting } = this.spec;
|
|
120
|
+
const params = [`${this.idParam}: string`, ...(acting ? [`${acting.serviceParam}: string`] : [])];
|
|
121
|
+
const load = `const ${this.model} = await this.get${this.modelClassName}(${this.idParam});`;
|
|
122
|
+
const owned = acting
|
|
123
|
+
? [
|
|
124
|
+
`// TODO: ${this.spec.guards.join(" and ")} admits any signed-in caller; refuse a ${acting.serviceParam} that may not ${this.#verb} this ${this.#noun}.`,
|
|
125
|
+
]
|
|
126
|
+
: [];
|
|
127
|
+
const chained = `${this.model}.${this.chainMethod}().save()`;
|
|
128
|
+
const head = ` async ${this.serviceMethod}(${params.join(", ")}) {`;
|
|
129
|
+
if (!this.spec.withCalls.length && !this.spec.afterCalls.length)
|
|
130
|
+
return [head, ` ${load}`, ...owned.map((line) => ` ${line}`), ` return await ${chained};`, " }"].join(
|
|
131
|
+
"\n",
|
|
132
|
+
);
|
|
133
|
+
return [
|
|
134
|
+
head,
|
|
135
|
+
" return await this.transaction(async () => {",
|
|
136
|
+
` ${load}`,
|
|
137
|
+
...owned.map((line) => ` ${line}`),
|
|
138
|
+
` await ${chained};`,
|
|
139
|
+
...this.spec.withCalls.map((call) => ` await ${MutationSource.#callOf(call)};`),
|
|
140
|
+
...this.spec.afterCalls.map((call) => ` await this.afterCommit(() => ${MutationSource.#callOf(call)});`),
|
|
141
|
+
` return ${this.model};`,
|
|
142
|
+
" });",
|
|
143
|
+
" }",
|
|
144
|
+
].join("\n");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
get signalEntry() {
|
|
148
|
+
const options = this.isPublic
|
|
149
|
+
? `{ guards: [${this.spec.guards.join(", ")}], rateLimit: { calls: 20 } }`
|
|
150
|
+
: `{ guards: [${this.spec.guards.join(", ")}] }`;
|
|
151
|
+
const { acting } = this.spec;
|
|
152
|
+
const execParams = [this.idParam, ...(acting ? [acting.param] : [])];
|
|
153
|
+
const serviceArgs = [this.idParam, ...(acting ? [acting.value] : [])];
|
|
154
|
+
return [
|
|
155
|
+
`${this.spec.mutation}: mutation(cnst.${this.modelClassName}, ${options})`,
|
|
156
|
+
` .param("${this.idParam}", ID)`,
|
|
157
|
+
...(acting ? [` .with(${acting.argName})`] : []),
|
|
158
|
+
` .exec(async function (${execParams.join(", ")}) {`,
|
|
159
|
+
` return await this.${this.model}Service.${this.serviceMethod}(${serviceArgs.join(", ")});`,
|
|
160
|
+
" }),",
|
|
161
|
+
].join("\n");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
get storeActionBlock() {
|
|
165
|
+
return [
|
|
166
|
+
` async ${this.spec.mutation}(${this.idParam}: string) {`,
|
|
167
|
+
` this.set${this.modelClassName}(await fetch.${this.spec.mutation}(${this.idParam}));`,
|
|
168
|
+
" }",
|
|
169
|
+
].join("\n");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
dictionaryEntryOf(modelKo?: string) {
|
|
173
|
+
const label = bilingualLabelForField(this.spec.mutation);
|
|
174
|
+
const modelLabel = { ...bilingualLabelForField(this.model), ...(modelKo ? { ko: modelKo } : {}) };
|
|
175
|
+
const pair = (en: string, ko: string) => `[${JSON.stringify(en)}, ${JSON.stringify(ko)}]`;
|
|
176
|
+
const idPair = pair(`${modelLabel.en} ID`, `${modelLabel.ko} ID`);
|
|
177
|
+
return [
|
|
178
|
+
`${this.spec.mutation}: fn(${pair(label.en, label.ko)})`,
|
|
179
|
+
` .desc(${pair(`API to ${label.en.toLowerCase()}`, `${label.ko} API`)})`,
|
|
180
|
+
" .arg((t) => ({",
|
|
181
|
+
` ${this.idParam}: t(${idPair}).desc(${idPair}),`,
|
|
182
|
+
" })),",
|
|
183
|
+
].join("\n");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
get testTitles() {
|
|
187
|
+
const guards = this.spec.guards.join(" and ");
|
|
188
|
+
const participle = MutationSource.participleOf(this.#verb);
|
|
189
|
+
return [
|
|
190
|
+
`${this.spec.mutation} leaves the ${this.#noun} ${participle} for a caller ${guards} admits`,
|
|
191
|
+
...(this.spec.guards.every((guard) => guard === "Public")
|
|
192
|
+
? []
|
|
193
|
+
: [`${this.spec.mutation} refuses a caller ${guards} turns away`]),
|
|
194
|
+
...(this.spec.acting
|
|
195
|
+
? [`${this.spec.mutation} refuses a signed-in caller who may not ${this.#verb} the ${this.#noun}`]
|
|
196
|
+
: []),
|
|
197
|
+
];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
testCaseLines(itName: string, indent: string) {
|
|
201
|
+
return this.testTitles.map((title) => `${indent}${itName}.todo(${JSON.stringify(title)});`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
get testFile() {
|
|
205
|
+
return [
|
|
206
|
+
'import { describe, it } from "bun:test";',
|
|
207
|
+
"",
|
|
208
|
+
`describe(${JSON.stringify(`${this.modelClassName} Signal`)}, () => {`,
|
|
209
|
+
...this.testCaseLines("it", " "),
|
|
210
|
+
"});",
|
|
211
|
+
"",
|
|
212
|
+
].join("\n");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
static #callOf(call: MutationCall) {
|
|
216
|
+
const target = call.field ? `this.${call.field}` : "this";
|
|
217
|
+
return `${target}.${call.method}(${call.args.join(", ")})`;
|
|
218
|
+
}
|
|
219
|
+
}
|
package/workflow/plan.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { capitalize } from "akanjs/common";
|
|
2
|
+
import type { Workspace } from "../commandDecorators";
|
|
2
3
|
import { workflowPlanApproval } from "./artifacts";
|
|
4
|
+
import { resolveWorkflowSys } from "./executor";
|
|
5
|
+
import { InteractionScaffold } from "./interactionScaffold";
|
|
6
|
+
import { MutationScaffold } from "./mutationScaffold";
|
|
3
7
|
import { coerceFieldDefault, moduleSourcePaths } from "./source";
|
|
4
8
|
import type {
|
|
5
9
|
WorkflowDiagnostic,
|
|
@@ -420,3 +424,53 @@ export const createWorkflowPlan = (spec: WorkflowSpec, rawInputs: Record<string,
|
|
|
420
424
|
approval: workflowPlanApproval,
|
|
421
425
|
};
|
|
422
426
|
};
|
|
427
|
+
|
|
428
|
+
const planString = (inputs: Record<string, WorkflowInputValue>, name: string) =>
|
|
429
|
+
typeof inputs[name] === "string" ? inputs[name] : null;
|
|
430
|
+
const planStrings = (inputs: Record<string, WorkflowInputValue>, name: string) =>
|
|
431
|
+
Array.isArray(inputs[name]) ? inputs[name] : null;
|
|
432
|
+
|
|
433
|
+
export const inspectWorkflowPlan = async (workspace: Workspace, plan: WorkflowPlan): Promise<WorkflowPlan> => {
|
|
434
|
+
if (plan.workflow !== "add-mutation" && plan.workflow !== "create-interaction") return plan;
|
|
435
|
+
const app = planString(plan.inputs, "app");
|
|
436
|
+
if (!app) return plan;
|
|
437
|
+
const sys = await resolveWorkflowSys(workspace, app).catch(() => null);
|
|
438
|
+
if (!sys)
|
|
439
|
+
return {
|
|
440
|
+
...plan,
|
|
441
|
+
diagnostics: [
|
|
442
|
+
...plan.diagnostics,
|
|
443
|
+
{
|
|
444
|
+
severity: "error",
|
|
445
|
+
code: "workflow-target-missing",
|
|
446
|
+
input: "app",
|
|
447
|
+
message: `No app or library named ${app}.`,
|
|
448
|
+
},
|
|
449
|
+
],
|
|
450
|
+
};
|
|
451
|
+
const inspected =
|
|
452
|
+
plan.workflow === "add-mutation"
|
|
453
|
+
? await MutationScaffold.build(
|
|
454
|
+
sys,
|
|
455
|
+
{
|
|
456
|
+
module: planString(plan.inputs, "module"),
|
|
457
|
+
mutation: planString(plan.inputs, "mutation"),
|
|
458
|
+
guards: planStrings(plan.inputs, "guard"),
|
|
459
|
+
withCalls: planStrings(plan.inputs, "with"),
|
|
460
|
+
afterCalls: planStrings(plan.inputs, "after"),
|
|
461
|
+
},
|
|
462
|
+
{ strict: false },
|
|
463
|
+
)
|
|
464
|
+
: { ...(await InteractionScaffold.build(sys, planString(plan.inputs, "interaction"))), recommendations: [] };
|
|
465
|
+
const failedInputs = new Set(
|
|
466
|
+
plan.diagnostics.filter((diagnostic) => diagnostic.severity === "error").map((diagnostic) => diagnostic.input),
|
|
467
|
+
);
|
|
468
|
+
return {
|
|
469
|
+
...plan,
|
|
470
|
+
diagnostics: [
|
|
471
|
+
...plan.diagnostics,
|
|
472
|
+
...inspected.diagnostics.filter((diagnostic) => !diagnostic.input || !failedInputs.has(diagnostic.input)),
|
|
473
|
+
],
|
|
474
|
+
recommendations: [...plan.recommendations, ...inspected.recommendations],
|
|
475
|
+
};
|
|
476
|
+
};
|
package/workflow/render.ts
CHANGED
|
@@ -33,7 +33,13 @@ export const renderWorkflowList = (specs: readonly WorkflowSpec[]) =>
|
|
|
33
33
|
[
|
|
34
34
|
"# Akan Workflows",
|
|
35
35
|
"",
|
|
36
|
-
...specs.flatMap((spec) => [
|
|
36
|
+
...specs.flatMap((spec) => [
|
|
37
|
+
`- \`${spec.name}\`: ${spec.description}`,
|
|
38
|
+
` - When: ${spec.whenToUse}`,
|
|
39
|
+
...(spec.replacesRules?.length
|
|
40
|
+
? [` - Fixes: ${spec.replacesRules.map((rule) => `\`${rule}\``).join(", ")}`]
|
|
41
|
+
: []),
|
|
42
|
+
]),
|
|
37
43
|
"",
|
|
38
44
|
].join("\n");
|
|
39
45
|
|
|
@@ -46,6 +52,14 @@ export const renderWorkflowExplain = (spec: WorkflowSpec) =>
|
|
|
46
52
|
"## When To Use",
|
|
47
53
|
spec.whenToUse,
|
|
48
54
|
"",
|
|
55
|
+
...(spec.replacesRules?.length
|
|
56
|
+
? [
|
|
57
|
+
"## Replaces Rules",
|
|
58
|
+
"This path is the fix when lint or the gate reports:",
|
|
59
|
+
...spec.replacesRules.map((rule) => `- \`${rule}\``),
|
|
60
|
+
"",
|
|
61
|
+
]
|
|
62
|
+
: []),
|
|
49
63
|
"## Inputs",
|
|
50
64
|
...Object.entries(spec.inputs).map(
|
|
51
65
|
([name, input]) =>
|
package/workflow/source.test.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import {
|
|
3
|
+
appendTopLevelTestCases,
|
|
4
|
+
classMethodParameters,
|
|
5
|
+
ensureImportDeclaration,
|
|
6
|
+
ensureNamedImport,
|
|
3
7
|
hasClassMethod,
|
|
8
|
+
hasDictionaryEndpointEntry,
|
|
4
9
|
hasSignalFactoryEntry,
|
|
5
10
|
hasSourceParseErrors,
|
|
6
11
|
insertClassMethod,
|
|
12
|
+
insertDictionaryEndpointEntry,
|
|
7
13
|
insertSignalFactoryEntry,
|
|
8
14
|
inspectDictionaryStructure,
|
|
9
15
|
} from "./source";
|
|
@@ -173,3 +179,127 @@ export class BannerEndpoint extends endpoint(srv.banner, ({ mutation }) => ({
|
|
|
173
179
|
expect(next).toBe(content);
|
|
174
180
|
});
|
|
175
181
|
});
|
|
182
|
+
|
|
183
|
+
describe("import editing", () => {
|
|
184
|
+
const signal = `import { endpoint, internal, slice } from "akanjs/signal";
|
|
185
|
+
|
|
186
|
+
import * as srv from "../srv";
|
|
187
|
+
`;
|
|
188
|
+
|
|
189
|
+
test("adds a name in Biome's order and keeps a type modifier", () => {
|
|
190
|
+
const content = 'import { type Dayjs, dayjs } from "akanjs/base";\n';
|
|
191
|
+
|
|
192
|
+
expect(ensureNamedImport(content, "akanjs/base", "ID")).toBe(
|
|
193
|
+
'import { type Dayjs, dayjs, ID } from "akanjs/base";\n',
|
|
194
|
+
);
|
|
195
|
+
expect(ensureNamedImport(signal, "akanjs/signal", "None")).toContain(
|
|
196
|
+
'import { endpoint, internal, None, slice } from "akanjs/signal";',
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("never adds a value name to a type-only import", () => {
|
|
201
|
+
const next = ensureNamedImport('import type { Guard } from "akanjs/signal";\n', "akanjs/signal", "Public");
|
|
202
|
+
|
|
203
|
+
expect(next).toContain('import type { Guard } from "akanjs/signal";');
|
|
204
|
+
expect(next).toContain('import { Public } from "akanjs/signal";');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("puts a new declaration in its chunk, in source order", () => {
|
|
208
|
+
const withPackages = ensureNamedImport(
|
|
209
|
+
ensureNamedImport(signal, "akanjs/base", "ID"),
|
|
210
|
+
"@libs/shared/srvkit",
|
|
211
|
+
"Admin",
|
|
212
|
+
);
|
|
213
|
+
const withRelative = ensureImportDeclaration(withPackages, "../cnst", "* as cnst");
|
|
214
|
+
|
|
215
|
+
expect(withRelative).toBe(`import { Admin } from "@libs/shared/srvkit";
|
|
216
|
+
import { ID } from "akanjs/base";
|
|
217
|
+
import { endpoint, internal, slice } from "akanjs/signal";
|
|
218
|
+
|
|
219
|
+
import * as cnst from "../cnst";
|
|
220
|
+
import * as srv from "../srv";
|
|
221
|
+
`);
|
|
222
|
+
expect(
|
|
223
|
+
ensureImportDeclaration('import { serve } from "akanjs/service";\n\nexport {};\n', "../srv", "type * as srv"),
|
|
224
|
+
).toBe('import { serve } from "akanjs/service";\n\nimport type * as srv from "../srv";\n\nexport {};\n');
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("dictionary endpoint entries", () => {
|
|
229
|
+
const dictionary = `export const dictionary = modelDictionary(["en", "ko"])
|
|
230
|
+
.slice<TaskSlice>((fn) => ({}))
|
|
231
|
+
.endpoint<TaskEndpoint>((fn) => ({}))
|
|
232
|
+
.error({})
|
|
233
|
+
.translate({});
|
|
234
|
+
`;
|
|
235
|
+
|
|
236
|
+
test("inserts an entry into the endpoint stage and finds it again", () => {
|
|
237
|
+
const next = insertDictionaryEndpointEntry(
|
|
238
|
+
dictionary,
|
|
239
|
+
"Task",
|
|
240
|
+
"archiveTask",
|
|
241
|
+
'archiveTask: fn(["Archive", "보관"]),',
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
expect(next).toContain('.endpoint<TaskEndpoint>((fn) => ({\n archiveTask: fn(["Archive", "보관"]),\n }))');
|
|
245
|
+
expect(hasDictionaryEndpointEntry(next ?? "", "Task", "archiveTask")).toBe(true);
|
|
246
|
+
expect(hasDictionaryEndpointEntry(dictionary, "Task", "archiveTask")).toBe(false);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("answers null when the dictionary has no endpoint stage for the model", () => {
|
|
250
|
+
expect(insertDictionaryEndpointEntry(dictionary, "Note", "pinNote", "pinNote: fn([]),")).toBeNull();
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe("classMethodParameters", () => {
|
|
255
|
+
test("reads names and which parameters a caller may leave out", () => {
|
|
256
|
+
const content = `export class NoteService {
|
|
257
|
+
async pushNote(taskId: string, note?: string, retries = 3) {}
|
|
258
|
+
}`;
|
|
259
|
+
|
|
260
|
+
expect(classMethodParameters(content, "NoteService", "pushNote")).toEqual([
|
|
261
|
+
{ name: "taskId", optional: false },
|
|
262
|
+
{ name: "note", optional: true },
|
|
263
|
+
{ name: "retries", optional: true },
|
|
264
|
+
]);
|
|
265
|
+
expect(classMethodParameters(content, "NoteService", "missing")).toBeNull();
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe("appendTopLevelTestCases", () => {
|
|
270
|
+
const render = (itName: string, indent: string) => [`${indent}${itName}.todo("archives");`];
|
|
271
|
+
|
|
272
|
+
test("appends inside the outermost describe with the runner name the file imports", () => {
|
|
273
|
+
const content = `import { describe, expect, test } from "bun:test";
|
|
274
|
+
|
|
275
|
+
describe("Task Signal", () => {
|
|
276
|
+
test("creates", () => {
|
|
277
|
+
expect(1).toBe(1);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
`;
|
|
281
|
+
|
|
282
|
+
expect(
|
|
283
|
+
appendTopLevelTestCases(content, "Task Signal", render),
|
|
284
|
+
).toBe(`import { describe, expect, test } from "bun:test";
|
|
285
|
+
|
|
286
|
+
describe("Task Signal", () => {
|
|
287
|
+
test("creates", () => {
|
|
288
|
+
expect(1).toBe(1);
|
|
289
|
+
});
|
|
290
|
+
test.todo("archives");
|
|
291
|
+
});
|
|
292
|
+
`);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("adds a describe when the file has none", () => {
|
|
296
|
+
const next = appendTopLevelTestCases('import { expect } from "bun:test";\n', "Task Signal", render);
|
|
297
|
+
|
|
298
|
+
expect(next).toBe(`import { describe, expect, it } from "bun:test";
|
|
299
|
+
|
|
300
|
+
describe("Task Signal", () => {
|
|
301
|
+
it.todo("archives");
|
|
302
|
+
});
|
|
303
|
+
`);
|
|
304
|
+
});
|
|
305
|
+
});
|
package/workflow/source.ts
CHANGED
|
@@ -41,6 +41,7 @@ export const moduleSourcePaths = (moduleName: string) => {
|
|
|
41
41
|
abstract: `lib/${moduleName}/${moduleName}.abstract.md`,
|
|
42
42
|
constant: `lib/${moduleName}/${moduleName}.constant.ts`,
|
|
43
43
|
dictionary: `lib/${moduleName}/${moduleName}.dictionary.ts`,
|
|
44
|
+
document: `lib/${moduleName}/${moduleName}.document.ts`,
|
|
44
45
|
service: `lib/${moduleName}/${moduleName}.service.ts`,
|
|
45
46
|
signal: `lib/${moduleName}/${moduleName}.signal.ts`,
|
|
46
47
|
store: `lib/${moduleName}/${moduleName}.store.ts`,
|
|
@@ -189,20 +190,52 @@ export const normalizeFieldType = (typeName: string) => {
|
|
|
189
190
|
return normalizedTypes[typeName.toLowerCase()] ?? typeName;
|
|
190
191
|
};
|
|
191
192
|
|
|
193
|
+
//? Biome's organizeImports order: case-insensitive, an uppercase name before its lowercase twin.
|
|
194
|
+
const importOrder = (a: string, b: string) => {
|
|
195
|
+
const [lowerA, lowerB] = [a.toLowerCase(), b.toLowerCase()];
|
|
196
|
+
if (lowerA !== lowerB) return lowerA < lowerB ? -1 : 1;
|
|
197
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
198
|
+
};
|
|
199
|
+
|
|
192
200
|
export const ensureNamedImport = (content: string, moduleSpecifier: string, name: string) => {
|
|
193
201
|
const source = sourceFileFor("source.ts", content);
|
|
194
|
-
const namedImport = findNamedImport(source, moduleSpecifier);
|
|
195
|
-
if (namedImport) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
202
|
+
const namedImport = findNamedImport(source, moduleSpecifier, { valueOnly: true });
|
|
203
|
+
if (!namedImport) return ensureImportDeclaration(content, moduleSpecifier, `{ ${name} }`);
|
|
204
|
+
if (namedImport.names.includes(name)) return content;
|
|
205
|
+
const nextElements = [...namedImport.elements, { name, text: name }].sort((a, b) => importOrder(a.name, b.name));
|
|
206
|
+
return spliceText(
|
|
207
|
+
content,
|
|
208
|
+
namedImport.namedBindingsStart,
|
|
209
|
+
namedImport.namedBindingsEnd,
|
|
210
|
+
`{ ${nextElements.map((element) => element.text).join(", ")} }`,
|
|
211
|
+
);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
export const ensureImportDeclaration = (content: string, moduleSpecifier: string, clause: string) => {
|
|
215
|
+
const line = `import ${clause} from "${moduleSpecifier}";`;
|
|
216
|
+
if (content.includes(line)) return content;
|
|
217
|
+
const source = sourceFileFor("source.ts", content);
|
|
218
|
+
const imports = source.statements
|
|
219
|
+
.filter((statement): statement is ts.ImportDeclaration => ts.isImportDeclaration(statement))
|
|
220
|
+
.flatMap((statement) =>
|
|
221
|
+
ts.isStringLiteral(statement.moduleSpecifier)
|
|
222
|
+
? [{ specifier: statement.moduleSpecifier.text, start: statement.getStart(source), end: statement.getEnd() }]
|
|
223
|
+
: [],
|
|
203
224
|
);
|
|
225
|
+
const isRelative = (specifier: string) => specifier.startsWith(".");
|
|
226
|
+
const peers = imports.filter((entry) => isRelative(entry.specifier) === isRelative(moduleSpecifier));
|
|
227
|
+
const before = peers.find((entry) => importOrder(entry.specifier, moduleSpecifier) > 0);
|
|
228
|
+
if (before) {
|
|
229
|
+
const insertAt = lineStartAt(content, before.start);
|
|
230
|
+
return spliceText(content, insertAt, insertAt, `${line}\n`);
|
|
231
|
+
}
|
|
232
|
+
const after = peers.at(-1) ?? (isRelative(moduleSpecifier) ? imports.at(-1) : undefined);
|
|
233
|
+
if (after) {
|
|
234
|
+
const insertAt = lineEndAt(content, after.end);
|
|
235
|
+
return spliceText(content, insertAt, insertAt, peers.length ? `${line}\n` : `\n${line}\n`);
|
|
204
236
|
}
|
|
205
|
-
|
|
237
|
+
const insertAt = imports[0] ? lineStartAt(content, imports[0].start) : 0;
|
|
238
|
+
return spliceText(content, insertAt, insertAt, `${line}\n\n`);
|
|
206
239
|
};
|
|
207
240
|
|
|
208
241
|
export const ensureBaseImport = (content: string, name: string) => ensureNamedImport(content, "akanjs/base", name);
|
|
@@ -382,6 +415,7 @@ interface ArrayInsertionLocator {
|
|
|
382
415
|
|
|
383
416
|
interface NamedImportLocator {
|
|
384
417
|
names: string[];
|
|
418
|
+
elements: { name: string; text: string }[];
|
|
385
419
|
namedBindingsStart: number;
|
|
386
420
|
namedBindingsEnd: number;
|
|
387
421
|
}
|
|
@@ -499,16 +533,23 @@ const dictionaryChainOrderValid = (chainMethods: readonly string[]) => {
|
|
|
499
533
|
return true;
|
|
500
534
|
};
|
|
501
535
|
|
|
502
|
-
const findDictionaryModelObject = (source: ts.SourceFile, moduleClassName: string)
|
|
536
|
+
const findDictionaryModelObject = (source: ts.SourceFile, moduleClassName: string) =>
|
|
537
|
+
findDictionaryStageObject(source, "model", moduleClassName);
|
|
538
|
+
|
|
539
|
+
const findDictionaryStageObject = (
|
|
540
|
+
source: ts.SourceFile,
|
|
541
|
+
stage: string,
|
|
542
|
+
typeName: string,
|
|
543
|
+
): DictionaryModelLocator | null => {
|
|
503
544
|
let locator: DictionaryModelLocator | null = null;
|
|
504
545
|
const visit = (node: ts.Node) => {
|
|
505
546
|
if (locator) return;
|
|
506
|
-
if (!ts.isCallExpression(node) || callExpressionName(node) !==
|
|
547
|
+
if (!ts.isCallExpression(node) || callExpressionName(node) !== stage) {
|
|
507
548
|
ts.forEachChild(node, visit);
|
|
508
549
|
return;
|
|
509
550
|
}
|
|
510
551
|
const typeArgument = node.typeArguments?.[0];
|
|
511
|
-
if (!typeArgument || typeArgument.getText(source) !==
|
|
552
|
+
if (!typeArgument || typeArgument.getText(source) !== typeName) {
|
|
512
553
|
ts.forEachChild(node, visit);
|
|
513
554
|
return;
|
|
514
555
|
}
|
|
@@ -556,14 +597,20 @@ const findLightProjectionArray = (source: ts.SourceFile, moduleClassName: string
|
|
|
556
597
|
return locator;
|
|
557
598
|
};
|
|
558
599
|
|
|
559
|
-
const findNamedImport = (
|
|
600
|
+
const findNamedImport = (
|
|
601
|
+
source: ts.SourceFile,
|
|
602
|
+
moduleSpecifier: string,
|
|
603
|
+
{ valueOnly = false }: { valueOnly?: boolean } = {},
|
|
604
|
+
): NamedImportLocator | null => {
|
|
560
605
|
for (const statement of source.statements) {
|
|
561
606
|
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue;
|
|
562
607
|
if (statement.moduleSpecifier.text !== moduleSpecifier) continue;
|
|
608
|
+
if (valueOnly && statement.importClause?.isTypeOnly) continue;
|
|
563
609
|
const namedBindings = statement.importClause?.namedBindings;
|
|
564
610
|
if (!namedBindings || !ts.isNamedImports(namedBindings)) continue;
|
|
565
611
|
return {
|
|
566
612
|
names: namedBindings.elements.map((element) => element.name.text),
|
|
613
|
+
elements: namedBindings.elements.map((element) => ({ name: element.name.text, text: element.getText(source) })),
|
|
567
614
|
namedBindingsStart: namedBindings.getStart(source),
|
|
568
615
|
namedBindingsEnd: namedBindings.getEnd(),
|
|
569
616
|
};
|
|
@@ -816,6 +863,27 @@ export const hasDictionaryModelField = (content: string, moduleClassName: string
|
|
|
816
863
|
return findDictionaryModelObject(source, moduleClassName)?.fields.some((field) => field.name === fieldName) ?? false;
|
|
817
864
|
};
|
|
818
865
|
|
|
866
|
+
export const hasDictionaryEndpointEntry = (content: string, moduleClassName: string, entryName: string) => {
|
|
867
|
+
const source = sourceFileFor("dictionary.ts", content);
|
|
868
|
+
return (
|
|
869
|
+
findDictionaryStageObject(source, "endpoint", `${moduleClassName}Endpoint`)?.fields.some(
|
|
870
|
+
(field) => field.name === entryName,
|
|
871
|
+
) ?? false
|
|
872
|
+
);
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
export const insertDictionaryEndpointEntry = (
|
|
876
|
+
content: string,
|
|
877
|
+
moduleClassName: string,
|
|
878
|
+
entryName: string,
|
|
879
|
+
entryText: string,
|
|
880
|
+
) => {
|
|
881
|
+
const source = sourceFileFor("dictionary.ts", content);
|
|
882
|
+
const locator = findDictionaryStageObject(source, "endpoint", `${moduleClassName}Endpoint`);
|
|
883
|
+
if (!locator) return null;
|
|
884
|
+
return insertOrderedFieldLine(content, locator, entryName, entryText, { fieldIndent: " ", closingIndent: " " });
|
|
885
|
+
};
|
|
886
|
+
|
|
819
887
|
export const ensureConstantTypeImport = (content: string, constantPath: string, typeName: string) => {
|
|
820
888
|
if (new RegExp(`import type \\{[^}]*\\b${typeName}\\b[^}]*\\} from "${constantPath}";`).test(content)) return content;
|
|
821
889
|
const importPattern = new RegExp(`import type \\{ ([^}]+) \\} from "${constantPath}";`);
|
|
@@ -883,6 +951,19 @@ export const hasClassMethod = (content: string, className: string, methodName: s
|
|
|
883
951
|
);
|
|
884
952
|
};
|
|
885
953
|
|
|
954
|
+
export const classMethodParameters = (content: string, className: string, methodName: string) => {
|
|
955
|
+
const source = sourceFileFor("service.ts", content);
|
|
956
|
+
const method = findClassDeclaration(source, className)?.members.find(
|
|
957
|
+
(member): member is ts.MethodDeclaration => ts.isMethodDeclaration(member) && nodeName(member.name) === methodName,
|
|
958
|
+
);
|
|
959
|
+
return (
|
|
960
|
+
method?.parameters.map((parameter) => ({
|
|
961
|
+
name: nodeName(parameter.name),
|
|
962
|
+
optional: !!parameter.questionToken || !!parameter.initializer || !!parameter.dotDotDotToken,
|
|
963
|
+
})) ?? null
|
|
964
|
+
);
|
|
965
|
+
};
|
|
966
|
+
|
|
886
967
|
export const insertClassMethod = (content: string, className: string, methodBlock: string): string | null => {
|
|
887
968
|
const source = sourceFileFor("service.ts", content);
|
|
888
969
|
const node = findClassDeclaration(source, className);
|
|
@@ -976,3 +1057,30 @@ export const insertSignalFactoryEntry = (
|
|
|
976
1057
|
if (withEntry === content) return null;
|
|
977
1058
|
return paramEdit === "unchanged" ? withEntry : spliceText(withEntry, paramEdit.start, paramEdit.end, paramEdit.text);
|
|
978
1059
|
};
|
|
1060
|
+
|
|
1061
|
+
export const appendTopLevelTestCases = (
|
|
1062
|
+
content: string,
|
|
1063
|
+
describeTitle: string,
|
|
1064
|
+
render: (itName: string, indent: string) => string[],
|
|
1065
|
+
) => {
|
|
1066
|
+
const bunImport = findNamedImport(sourceFileFor("test.ts", content), "bun:test", { valueOnly: true });
|
|
1067
|
+
const itName = !bunImport?.names.includes("it") && bunImport?.names.includes("test") ? "test" : "it";
|
|
1068
|
+
const imported = ensureNamedImport(content, "bun:test", itName);
|
|
1069
|
+
const source = sourceFileFor("test.ts", imported);
|
|
1070
|
+
const body = source.statements.flatMap((statement) => {
|
|
1071
|
+
if (!ts.isExpressionStatement(statement) || !ts.isCallExpression(statement.expression)) return [];
|
|
1072
|
+
if (expressionName(statement.expression.expression) !== "describe") return [];
|
|
1073
|
+
const callback = factoryArrowOf(statement.expression);
|
|
1074
|
+
return callback && ts.isBlock(callback.body) ? [callback.body] : [];
|
|
1075
|
+
})[0];
|
|
1076
|
+
if (!body) {
|
|
1077
|
+
const block = [`describe(${JSON.stringify(describeTitle)}, () => {`, ...render(itName, " "), "});", ""];
|
|
1078
|
+
return `${ensureNamedImport(imported, "bun:test", "describe").trimEnd()}\n\n${block.join("\n")}`;
|
|
1079
|
+
}
|
|
1080
|
+
const firstStatement = body.statements[0];
|
|
1081
|
+
const indent = firstStatement
|
|
1082
|
+
? lineIndentAt(imported, firstStatement.getStart(source))
|
|
1083
|
+
: `${lineIndentAt(imported, body.getStart(source))} `;
|
|
1084
|
+
const insertAt = lineStartAt(imported, body.getEnd() - 1);
|
|
1085
|
+
return spliceText(imported, insertAt, insertAt, `${render(itName, indent).join("\n")}\n`);
|
|
1086
|
+
};
|