@akanjs/devkit 3.0.0-alpha.15 → 3.0.0-alpha.17
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/README.ko.md +1 -1
- package/README.md +1 -1
- package/akanConfig/akanConfig.test.ts +0 -10
- package/akanConfig/akanConfig.ts +1 -11
- package/applicationBuildRunner.test.ts +1 -1
- package/applicationBuildRunner.ts +0 -1
- package/biome.base.json +4 -0
- package/executors.test.ts +3 -4
- package/executors.ts +1 -1
- package/frontendBuild/buildRouteClient.test.ts +28 -2
- package/frontendBuild/clientBuildTypes.ts +4 -0
- package/frontendBuild/clientEntriesBundler.ts +4 -1
- package/frontendBuild/routeClientBuilder.ts +12 -5
- package/frontendBuild/ssrBaseArtifactBuilder.ts +1 -1
- package/getCredentials.ts +1 -3
- package/lint/no-model-type-in-util-zone.grit +16 -0
- package/package.json +3 -4
- package/qualityScanner.test.ts +0 -169
- package/qualityScanner.ts +1 -11
- package/transforms/externalizeFrameworkPlugin.ts +0 -1
- package/mcpScanner.ts +0 -217
- package/storeScanner.ts +0 -173
package/README.ko.md
CHANGED
package/README.md
CHANGED
|
@@ -60,7 +60,7 @@ defaults, the shares they derive from `AKAN_MEMORY_LIMIT`, and what a small cont
|
|
|
60
60
|
|
|
61
61
|
## Requirements
|
|
62
62
|
|
|
63
|
-
- [Bun](https://bun.sh) `>=1.
|
|
63
|
+
- [Bun](https://bun.sh) `>=1.4.0`
|
|
64
64
|
- TypeScript
|
|
65
65
|
- Optional peers are only needed for the features that use them, such as Capacitor integration.
|
|
66
66
|
|
|
@@ -19,7 +19,6 @@ const packageJson: PackageJson = {
|
|
|
19
19
|
react: "19.0.0",
|
|
20
20
|
"react-dom": "19.0.0",
|
|
21
21
|
"react-server-dom-webpack": "19.0.0",
|
|
22
|
-
sharp: "1.0.0",
|
|
23
22
|
"@external/runtime": "2.0.0",
|
|
24
23
|
},
|
|
25
24
|
};
|
|
@@ -144,8 +143,6 @@ describe("AkanAppConfig", () => {
|
|
|
144
143
|
react: "19.0.0",
|
|
145
144
|
"react-dom": "19.0.0",
|
|
146
145
|
"react-server-dom-webpack": "19.0.0",
|
|
147
|
-
croner: akanPackageJson.peerDependencies?.croner,
|
|
148
|
-
sharp: "1.0.0",
|
|
149
146
|
"@external/runtime": "2.0.0",
|
|
150
147
|
},
|
|
151
148
|
});
|
|
@@ -184,8 +181,6 @@ describe("AkanAppConfig", () => {
|
|
|
184
181
|
react: runtimeDependencies.react,
|
|
185
182
|
"react-dom": runtimeDependencies["react-dom"],
|
|
186
183
|
"react-server-dom-webpack": runtimeDependencies["react-server-dom-webpack"],
|
|
187
|
-
croner: runtimeDependencies.croner,
|
|
188
|
-
sharp: runtimeDependencies.sharp,
|
|
189
184
|
});
|
|
190
185
|
});
|
|
191
186
|
|
|
@@ -198,9 +193,6 @@ describe("AkanAppConfig", () => {
|
|
|
198
193
|
const multipleConfig = new AkanAppConfig(app, [], packageJson, { defaultDatabaseMode: "multiple" }, baseDevEnv);
|
|
199
194
|
const clusterConfig = new AkanAppConfig(app, [], packageJson, { defaultDatabaseMode: "cluster" }, baseDevEnv);
|
|
200
195
|
|
|
201
|
-
expect(singleConfig.getProductionPackageJson().dependencies).toMatchObject({
|
|
202
|
-
croner: runtimeDependencies.croner,
|
|
203
|
-
});
|
|
204
196
|
expect(singleConfig.getProductionPackageJson().dependencies).not.toHaveProperty("ioredis");
|
|
205
197
|
expect(singleConfig.getProductionPackageJson().dependencies).not.toHaveProperty("bullmq");
|
|
206
198
|
expect(singleConfig.getProductionPackageJson().dependencies).not.toHaveProperty("@libsql/client");
|
|
@@ -210,7 +202,6 @@ describe("AkanAppConfig", () => {
|
|
|
210
202
|
expect(multipleConfig.getProductionPackageJson().dependencies).toMatchObject({
|
|
211
203
|
"@libsql/client": runtimeDependencies["@libsql/client"],
|
|
212
204
|
bullmq: runtimeDependencies.bullmq,
|
|
213
|
-
croner: runtimeDependencies.croner,
|
|
214
205
|
ioredis: runtimeDependencies.ioredis,
|
|
215
206
|
protobufjs: runtimeDependencies.protobufjs,
|
|
216
207
|
});
|
|
@@ -218,7 +209,6 @@ describe("AkanAppConfig", () => {
|
|
|
218
209
|
|
|
219
210
|
expect(clusterConfig.getProductionPackageJson().dependencies).toMatchObject({
|
|
220
211
|
bullmq: runtimeDependencies.bullmq,
|
|
221
|
-
croner: runtimeDependencies.croner,
|
|
222
212
|
ioredis: runtimeDependencies.ioredis,
|
|
223
213
|
postgres: runtimeDependencies.postgres,
|
|
224
214
|
protobufjs: runtimeDependencies.protobufjs,
|
package/akanConfig/akanConfig.ts
CHANGED
|
@@ -50,8 +50,6 @@ const DEFAULT_OPTIMIZE_IMPORTS = [
|
|
|
50
50
|
];
|
|
51
51
|
const WORKSPACE_BARREL_FACETS = ["ui", "webkit", "common", "client", "server"] as const;
|
|
52
52
|
const SSR_RUNTIME_PACKAGES = ["react", "react-dom", "react-server-dom-webpack"] as const;
|
|
53
|
-
const NATIVE_RUNTIME_PACKAGES = ["sharp"] as const;
|
|
54
|
-
const DEFAULT_BACKEND_RUNTIME_PACKAGES = ["croner"] as const;
|
|
55
53
|
// The firebase client (push tokens) and the Capacitor toolchain — `@capacitor/cli` (`npx cap`),
|
|
56
54
|
// `@capacitor/assets` (`npx @capacitor/assets`) plus the `@capacitor/core`/`ios`/`android` runtime
|
|
57
55
|
// and native-platform packages that `npx cap add`/`sync` resolve from the workspace node_modules.
|
|
@@ -92,8 +90,6 @@ const DATABASE_MODE_RUNTIME_PACKAGES = {
|
|
|
92
90
|
} satisfies Record<DatabaseMode, readonly string[]>;
|
|
93
91
|
const AKAN_RUNTIME_PACKAGES = new Set<string>([
|
|
94
92
|
...SSR_RUNTIME_PACKAGES,
|
|
95
|
-
...NATIVE_RUNTIME_PACKAGES,
|
|
96
|
-
...DEFAULT_BACKEND_RUNTIME_PACKAGES,
|
|
97
93
|
...MOBILE_RUNTIME_PACKAGES,
|
|
98
94
|
...Object.values(DATABASE_MODE_RUNTIME_PACKAGES).flat(),
|
|
99
95
|
]);
|
|
@@ -428,13 +424,7 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
|
|
|
428
424
|
return akanPackageJson.dependencies?.[lib] ?? akanPackageJson.peerDependencies?.[lib];
|
|
429
425
|
}
|
|
430
426
|
#getProductionRuntimePackages() {
|
|
431
|
-
return [
|
|
432
|
-
...this.externalLibs,
|
|
433
|
-
...SSR_RUNTIME_PACKAGES,
|
|
434
|
-
...NATIVE_RUNTIME_PACKAGES,
|
|
435
|
-
...DEFAULT_BACKEND_RUNTIME_PACKAGES,
|
|
436
|
-
...this.getDatabaseModeRuntimePackages(),
|
|
437
|
-
];
|
|
427
|
+
return [...this.externalLibs, ...SSR_RUNTIME_PACKAGES, ...this.getDatabaseModeRuntimePackages()];
|
|
438
428
|
}
|
|
439
429
|
getDatabaseModeRuntimePackages(databaseMode: DatabaseMode = this.defaultDatabaseMode) {
|
|
440
430
|
return [...DATABASE_MODE_RUNTIME_PACKAGES[databaseMode]];
|
|
@@ -4,7 +4,7 @@ import { AKAN_OPTIONAL_BACKEND_EXTERNALS } from "./applicationBuildRunner";
|
|
|
4
4
|
describe("ApplicationBuildRunner", () => {
|
|
5
5
|
test("externalizes Akan optional backend dependencies", () => {
|
|
6
6
|
expect(AKAN_OPTIONAL_BACKEND_EXTERNALS).toEqual(
|
|
7
|
-
expect.arrayContaining(["@libsql/client", "bullmq", "
|
|
7
|
+
expect.arrayContaining(["@libsql/client", "bullmq", "ioredis", "postgres", "protobufjs"]),
|
|
8
8
|
);
|
|
9
9
|
});
|
|
10
10
|
});
|
package/biome.base.json
CHANGED
|
@@ -174,6 +174,10 @@
|
|
|
174
174
|
"includes": ["{apps,libs}/**/*.signal.ts"],
|
|
175
175
|
"plugins": ["./node_modules/@akanjs/devkit/lint/no-redeclare-predefined-endpoint.grit"]
|
|
176
176
|
},
|
|
177
|
+
{
|
|
178
|
+
"includes": ["{apps,libs}/**/*.Util.tsx", "{apps,libs}/**/*.Zone.tsx"],
|
|
179
|
+
"plugins": ["./node_modules/@akanjs/devkit/lint/no-model-type-in-util-zone.grit"]
|
|
180
|
+
},
|
|
177
181
|
{
|
|
178
182
|
"includes": [
|
|
179
183
|
"**/ui/**/*.ts",
|
package/executors.test.ts
CHANGED
|
@@ -31,7 +31,6 @@ const rootPackageJson = (extra: Partial<PackageJson> = {}): PackageJson => ({
|
|
|
31
31
|
react: "19.0.0",
|
|
32
32
|
"react-dom": "19.0.0",
|
|
33
33
|
"react-server-dom-webpack": "19.0.0",
|
|
34
|
-
sharp: "1.0.0",
|
|
35
34
|
lodash: "4.0.0",
|
|
36
35
|
},
|
|
37
36
|
devDependencies: {
|
|
@@ -796,7 +795,7 @@ describe("PkgExecutor package generation", () => {
|
|
|
796
795
|
exports: { "./extra": { import: "./extra.ts" } },
|
|
797
796
|
peerDependencies: { react: "19.0.0" },
|
|
798
797
|
peerDependenciesMeta: { react: { optional: true } },
|
|
799
|
-
optionalDependencies: {
|
|
798
|
+
optionalDependencies: { "@sample/native": "1.0.0" },
|
|
800
799
|
});
|
|
801
800
|
|
|
802
801
|
const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
|
|
@@ -806,12 +805,12 @@ describe("PkgExecutor package generation", () => {
|
|
|
806
805
|
expect(distPackageJson).toMatchObject({
|
|
807
806
|
name: "@sample/tool",
|
|
808
807
|
type: "module",
|
|
809
|
-
engines: { bun: ">=1.
|
|
808
|
+
engines: { bun: ">=1.4.0" },
|
|
810
809
|
dependencies: { lodash: "4.0.0" },
|
|
811
810
|
devDependencies: { typescript: "6.0.0" },
|
|
812
811
|
peerDependencies: { react: "19.0.0" },
|
|
813
812
|
peerDependenciesMeta: { react: { optional: true } },
|
|
814
|
-
optionalDependencies: {
|
|
813
|
+
optionalDependencies: { "@sample/native": "1.0.0" },
|
|
815
814
|
});
|
|
816
815
|
expect(distPackageJson.exports?.["."]).toEqual({
|
|
817
816
|
import: "./index.ts",
|
package/executors.ts
CHANGED
|
@@ -1813,7 +1813,7 @@ export class PkgExecutor extends Executor {
|
|
|
1813
1813
|
default: "./index.ts",
|
|
1814
1814
|
},
|
|
1815
1815
|
},
|
|
1816
|
-
engines: { bun: ">=1.
|
|
1816
|
+
engines: { bun: ">=1.4.0" },
|
|
1817
1817
|
...dependencyMaps,
|
|
1818
1818
|
};
|
|
1819
1819
|
await Promise.all([this.dist.writeJson("package.json", distPkgJson), this.writeJson("package.json", distPkgJson)]);
|
|
@@ -82,16 +82,42 @@ describe("route client store bootstrap", () => {
|
|
|
82
82
|
});
|
|
83
83
|
|
|
84
84
|
test("bundles akan fetch into production SSR client chunks", () => {
|
|
85
|
-
expect(RouteClientBuilder.
|
|
85
|
+
expect(RouteClientBuilder.resolveSsrClientBundleOptions("start")).toMatchObject({
|
|
86
86
|
external: expect.arrayContaining(["akanjs/fetch"]),
|
|
87
87
|
externalSubpaths: ["akanjs/fetch"],
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
-
expect(RouteClientBuilder.
|
|
90
|
+
expect(RouteClientBuilder.resolveSsrClientBundleOptions("build")).toEqual({
|
|
91
|
+
target: "bun",
|
|
91
92
|
external: ["react", "react-dom", "react-dom/client", "react/jsx-runtime", "react/jsx-dev-runtime"],
|
|
92
93
|
});
|
|
93
94
|
});
|
|
94
95
|
|
|
96
|
+
test("targets the server so SSR client chunks never resolve a browser export condition", async () => {
|
|
97
|
+
const root = await makeTempRoot();
|
|
98
|
+
const pkgDir = path.join(root, "node_modules/dom-conditioned-pkg");
|
|
99
|
+
await write(
|
|
100
|
+
path.join(pkgDir, "package.json"),
|
|
101
|
+
JSON.stringify({
|
|
102
|
+
name: "dom-conditioned-pkg",
|
|
103
|
+
type: "module",
|
|
104
|
+
exports: { ".": { browser: "./index.dom.js", default: "./index.js" } },
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
await write(path.join(pkgDir, "index.dom.js"), 'export const element = document.createElement("i");\n');
|
|
108
|
+
await write(path.join(pkgDir, "index.js"), "export const element = null;\n");
|
|
109
|
+
const entry = path.join(root, "entry.ts");
|
|
110
|
+
await write(entry, 'export { element } from "dom-conditioned-pkg";\n');
|
|
111
|
+
|
|
112
|
+
for (const command of ["start", "build"] as const) {
|
|
113
|
+
const { target } = RouteClientBuilder.resolveSsrClientBundleOptions(command);
|
|
114
|
+
const built = await Bun.build({ entrypoints: [entry], target, format: "esm" });
|
|
115
|
+
|
|
116
|
+
expect(built.success).toBe(true);
|
|
117
|
+
expect(await built.outputs[0].text()).not.toContain("document.createElement");
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
95
121
|
test("rewrites SSR external imports to runtime aliases", () => {
|
|
96
122
|
const source = [
|
|
97
123
|
'import React, { useState } from "react";',
|
|
@@ -55,6 +55,8 @@ export interface BuildClientResult {
|
|
|
55
55
|
rscClientUrl: string;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
export type ClientBundleTarget = "browser" | "bun";
|
|
59
|
+
|
|
58
60
|
export const CLIENT_BUNDLE_NAMING = {
|
|
59
61
|
entry: "[name]-[hash].[ext]",
|
|
60
62
|
chunk: "chunks/[hash].[ext]",
|
|
@@ -79,6 +81,8 @@ export interface BundleClientEntriesOptions {
|
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
export interface BundleClientEntriesInternalOptions extends BundleClientEntriesOptions {
|
|
84
|
+
/** Module-resolution target. `"bun"` for the server-executed `client-ssr` bundle, `"browser"` otherwise. */
|
|
85
|
+
target?: ClientBundleTarget;
|
|
82
86
|
external?: readonly string[];
|
|
83
87
|
externalSubpaths?: readonly string[];
|
|
84
88
|
externalAliases?: Partial<Record<string, string>>;
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type BundleClientEntriesInternalOptions,
|
|
8
8
|
type BundleClientEntriesResult,
|
|
9
9
|
CLIENT_BUNDLE_NAMING,
|
|
10
|
+
type ClientBundleTarget,
|
|
10
11
|
type ClientManifest,
|
|
11
12
|
type MetafileOutput,
|
|
12
13
|
type OpaqueEntryAliases,
|
|
@@ -25,6 +26,7 @@ export class ClientEntriesBundler {
|
|
|
25
26
|
#externalSubpaths: readonly string[];
|
|
26
27
|
#externalAliases: Partial<Record<string, string>>;
|
|
27
28
|
#command: "build" | "start";
|
|
29
|
+
#target: ClientBundleTarget;
|
|
28
30
|
#outputSubdir: string;
|
|
29
31
|
#reactFastRefresh: boolean;
|
|
30
32
|
#artifactDir: string;
|
|
@@ -47,6 +49,7 @@ export class ClientEntriesBundler {
|
|
|
47
49
|
this.#externalSubpaths = options.externalSubpaths ?? [];
|
|
48
50
|
this.#externalAliases = options.externalAliases ?? {};
|
|
49
51
|
this.#command = options.command ?? "start";
|
|
52
|
+
this.#target = options.target ?? "browser";
|
|
50
53
|
this.#outputSubdir = options.outputSubdir ?? "client";
|
|
51
54
|
this.#reactFastRefresh = options.reactFastRefresh ?? false;
|
|
52
55
|
this.#artifactDir = `${this.#command === "build" ? this.#app.dist.cwdPath : this.#app.cwdPath}/.akan/artifact`;
|
|
@@ -61,7 +64,7 @@ export class ClientEntriesBundler {
|
|
|
61
64
|
entrypoints: this.#opaqueEntries.entries,
|
|
62
65
|
outdir: this.#outdir,
|
|
63
66
|
splitting: true,
|
|
64
|
-
target:
|
|
67
|
+
target: this.#target,
|
|
65
68
|
format: "esm",
|
|
66
69
|
naming: CLIENT_BUNDLE_NAMING,
|
|
67
70
|
metafile: true,
|
|
@@ -4,7 +4,7 @@ import type { BaseBuildArtifact, ClientManifest, SsrManifest } from "akanjs/serv
|
|
|
4
4
|
import type { App } from "../commandDecorators";
|
|
5
5
|
import { createBarrelImportsPlugin } from "../transforms/barrelImportsPlugin";
|
|
6
6
|
import { toClientReferencePath } from "../transforms/rscUseClientTransform";
|
|
7
|
-
import type { ClientEntryDiscovery } from "./clientBuildTypes";
|
|
7
|
+
import type { ClientBundleTarget, ClientEntryDiscovery } from "./clientBuildTypes";
|
|
8
8
|
import { ClientEntriesBundler } from "./clientEntriesBundler";
|
|
9
9
|
import { GraphClientEntryDiscovery } from "./clientEntryDiscovery";
|
|
10
10
|
import { VENDOR_SPECIFIERS } from "./vendorSpecifiers";
|
|
@@ -163,12 +163,11 @@ export class RouteClientBuilder {
|
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
async #buildSsrBundle(bootstrapEntries: BootstrapEntries) {
|
|
166
|
-
const externalOptions = RouteClientBuilder.resolveSsrClientExternalOptions(this.#command);
|
|
167
166
|
return new ClientEntriesBundler({
|
|
168
167
|
app: this.#app,
|
|
169
168
|
entries: bootstrapEntries.buildEntries,
|
|
170
169
|
plugins: [await createBarrelImportsPlugin(this.#app)],
|
|
171
|
-
...
|
|
170
|
+
...RouteClientBuilder.resolveSsrClientBundleOptions(this.#command),
|
|
172
171
|
outputSubdir: "client-ssr",
|
|
173
172
|
command: this.#command,
|
|
174
173
|
}).bundle();
|
|
@@ -239,20 +238,28 @@ export class RouteClientBuilder {
|
|
|
239
238
|
return { [Bun.resolveSync("akanjs/fetch", serverEntry)]: "akanjs/fetch" };
|
|
240
239
|
}
|
|
241
240
|
|
|
242
|
-
|
|
241
|
+
/**
|
|
242
|
+
* `target: "bun"` is load-bearing: these chunks are `await import()`-ed by the SSR renderer, so a dependency
|
|
243
|
+
* resolved through its `browser` export condition can touch `document` at module scope and throw mid-render,
|
|
244
|
+
* degrading the whole document to client rendering. Bun's `conditions` only adds to the target's defaults —
|
|
245
|
+
* `browser` still wins — so the target itself has to say server.
|
|
246
|
+
*/
|
|
247
|
+
static resolveSsrClientBundleOptions(command: "build" | "start"): {
|
|
248
|
+
target: ClientBundleTarget;
|
|
243
249
|
external: readonly string[];
|
|
244
250
|
externalSubpaths?: readonly string[];
|
|
245
251
|
externalAliases?: Record<string, string>;
|
|
246
252
|
} {
|
|
247
253
|
if (command === "start") {
|
|
248
254
|
return {
|
|
255
|
+
target: "bun",
|
|
249
256
|
external: SSR_CLIENT_EXTERNALS,
|
|
250
257
|
externalSubpaths: ["akanjs/fetch"],
|
|
251
258
|
externalAliases: RouteClientBuilder.resolveSsrClientRuntimeAliases(),
|
|
252
259
|
};
|
|
253
260
|
}
|
|
254
261
|
|
|
255
|
-
return { external: SSR_CLIENT_ALIAS_EXTERNALS };
|
|
262
|
+
return { target: "bun", external: SSR_CLIENT_ALIAS_EXTERNALS };
|
|
256
263
|
}
|
|
257
264
|
|
|
258
265
|
static resolveAkanServerEntry(): string {
|
|
@@ -110,7 +110,7 @@ export class SsrBaseArtifactBuilder {
|
|
|
110
110
|
const ssrBundle = await new ClientEntriesBundler({
|
|
111
111
|
app: this.#app,
|
|
112
112
|
entries: [rscSegmentOutletEntry],
|
|
113
|
-
...RouteClientBuilder.
|
|
113
|
+
...RouteClientBuilder.resolveSsrClientBundleOptions(this.#command),
|
|
114
114
|
outputSubdir: "client-ssr",
|
|
115
115
|
command: this.#command,
|
|
116
116
|
}).bundle();
|
package/getCredentials.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import yaml from "js-yaml";
|
|
2
|
-
|
|
3
1
|
import type { AppExecutor } from "./executors";
|
|
4
2
|
import { FileSys } from "./fileSys";
|
|
5
3
|
|
|
@@ -12,7 +10,7 @@ interface Secret {
|
|
|
12
10
|
|
|
13
11
|
export const getCredentials = async (app: AppExecutor, environment: string): Promise<AppSecret> => {
|
|
14
12
|
const content = await FileSys.readText(`${app.workspace.workspaceRoot}/infra/app/values/${app.name}-secret.yaml`);
|
|
15
|
-
const secret =
|
|
13
|
+
const secret = Bun.YAML.parse(content) as Secret;
|
|
16
14
|
const appSecret = secret[environment];
|
|
17
15
|
if (!appSecret) throw new Error(`No secret found for ${app.name} in ${environment}`);
|
|
18
16
|
return appSecret;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
TsReferenceType() as $ref where {
|
|
5
|
+
$ref <: r"cnst\.[A-Za-z_$][A-Za-z0-9_$]*",
|
|
6
|
+
not $ref <: within TsIndexedAccessType() as $indexed where {
|
|
7
|
+
$indexed <: r"cnst\.[A-Za-z_$][A-Za-z0-9_$]*\[.value.\][\s\S]*"
|
|
8
|
+
},
|
|
9
|
+
not $ref <: within TsReferenceType() as $wrapper where {
|
|
10
|
+
$wrapper <: r"(?:Client|Server)(?:Init|View|Edit)<[\s\S]*"
|
|
11
|
+
},
|
|
12
|
+
register_diagnostic(
|
|
13
|
+
span = $ref,
|
|
14
|
+
message = "Util and Zone are client components, so a cnst model type here is a class instance crossing the server boundary. Take an id instead (bannerId: string), or hand server data across as ClientInit / ClientView. An enum union such as cnst.AdminRole['value'] is allowed."
|
|
15
|
+
)
|
|
16
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.17",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"directory": "pkgs/@akanjs/devkit"
|
|
15
15
|
},
|
|
16
16
|
"engines": {
|
|
17
|
-
"bun": ">=1.
|
|
17
|
+
"bun": ">=1.4.0"
|
|
18
18
|
},
|
|
19
19
|
"exports": {
|
|
20
20
|
".": {
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"@langchain/openai": "^1.4.6",
|
|
46
46
|
"@tailwindcss/node": "^4.3.0",
|
|
47
47
|
"@trapezedev/project": "^7.1.4",
|
|
48
|
-
"akanjs": "3.0.0-alpha.
|
|
48
|
+
"akanjs": "3.0.0-alpha.17",
|
|
49
49
|
"chalk": "^5.6.2",
|
|
50
50
|
"commander": "^14.0.3",
|
|
51
51
|
"dayjs": "^1.11.20",
|
|
@@ -53,7 +53,6 @@
|
|
|
53
53
|
"fonteditor-core": "^2.6.3",
|
|
54
54
|
"ignore": "^7.0.5",
|
|
55
55
|
"ink": "^6.8.0",
|
|
56
|
-
"js-yaml": "^4.1.1",
|
|
57
56
|
"ora": "^9.4.0",
|
|
58
57
|
"ssh2": "^1.17.0",
|
|
59
58
|
"subset-font": "^2.5.0",
|
package/qualityScanner.test.ts
CHANGED
|
@@ -193,175 +193,6 @@ const signalOf = (entries: string) =>
|
|
|
193
193
|
"",
|
|
194
194
|
].join("\n");
|
|
195
195
|
|
|
196
|
-
describe("AkanQualityScanner mcp rules", () => {
|
|
197
|
-
test("flags an exposed endpoint whose dictionary entry carries no desc", async () => {
|
|
198
|
-
const root = await makeWorkspace({
|
|
199
|
-
"libs/shared/lib/post/post.signal.ts": [
|
|
200
|
-
`import { endpoint } from "akanjs/signal";`,
|
|
201
|
-
`export class PostEndpoint extends endpoint(srv.post, ({ query }) => ({`,
|
|
202
|
-
` publishPost: query(Boolean, { guards: [Admin], mcp: { expose: true } }).exec(() => true),`,
|
|
203
|
-
` archivePost: query(Boolean, { guards: [Admin], mcp: { expose: true } }).exec(() => true),`,
|
|
204
|
-
` quietPost: query(Boolean, { guards: [Admin] }).exec(() => true),`,
|
|
205
|
-
`})) {}`,
|
|
206
|
-
"",
|
|
207
|
-
].join("\n"),
|
|
208
|
-
"libs/shared/lib/post/post.dictionary.ts": [
|
|
209
|
-
`export const dictionary = modelDictionary(["en", "ko"]).endpoint((fn) => ({`,
|
|
210
|
-
` publishPost: fn(["Publish", "게시"]).desc(["Publishes a post", "글을 게시합니다"]),`,
|
|
211
|
-
` archivePost: fn(["Archive", "보관"]).arg((t) => ({`,
|
|
212
|
-
` postId: t(["Post", "글"]).desc(["Post to archive", "보관할 글"]),`,
|
|
213
|
-
` })),`,
|
|
214
|
-
`}));`,
|
|
215
|
-
"",
|
|
216
|
-
].join("\n"),
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.mcp.missing-description");
|
|
220
|
-
|
|
221
|
-
// `archivePost` describes only its argument, which says nothing about when to reach for the tool.
|
|
222
|
-
expect(warnings).toHaveLength(1);
|
|
223
|
-
expect(warnings[0]?.message).toContain("archivePost");
|
|
224
|
-
expect(warnings[0]?.line).toBe(4);
|
|
225
|
-
expect(warnings[0]?.fix).toContain(".desc(");
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
test("accepts a slice described on its own entry or on the endpoint it generates", async () => {
|
|
229
|
-
const root = await makeWorkspace({
|
|
230
|
-
"libs/shared/lib/post/post.signal.ts": signalOf(
|
|
231
|
-
[
|
|
232
|
-
` inPublic: init({ mcp: { expose: true } }).exec(function () { return this.postService.queryInPublic(); }),`,
|
|
233
|
-
` inTag: init({ mcp: { expose: true } }).exec(function () { return this.postService.queryInTag(); }),`,
|
|
234
|
-
` inDraft: init({ mcp: { expose: true } }).exec(function () { return this.postService.queryInDraft(); }),`,
|
|
235
|
-
].join("\n"),
|
|
236
|
-
),
|
|
237
|
-
"libs/shared/lib/post/post.dictionary.ts": [
|
|
238
|
-
`export const dictionary = modelDictionary(["en", "ko"])`,
|
|
239
|
-
` .slice((fn) => ({`,
|
|
240
|
-
` inPublic: fn(["In Public", "공개"]).desc(["Public posts", "공개된 글"]),`,
|
|
241
|
-
` inTag: fn(["In Tag", "태그"]),`,
|
|
242
|
-
` inDraft: fn(["In Draft", "초안"]),`,
|
|
243
|
-
` }))`,
|
|
244
|
-
` .endpoint((fn) => ({`,
|
|
245
|
-
` postListInTag: fn(["Post List In Tag", "태그별 글"]).desc(["Posts under a tag", "태그에 속한 글"]),`,
|
|
246
|
-
` }));`,
|
|
247
|
-
"",
|
|
248
|
-
].join("\n"),
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.mcp.missing-description");
|
|
252
|
-
|
|
253
|
-
expect(warnings).toHaveLength(1);
|
|
254
|
-
expect(warnings[0]?.message).toContain("inDraft");
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
test("flags an exposure that declares no guards, whatever the slice call declared", async () => {
|
|
258
|
-
const root = await makeWorkspace({
|
|
259
|
-
"libs/shared/lib/post/post.signal.ts": signalOf(
|
|
260
|
-
[
|
|
261
|
-
` inPublic: init({ guards: [Public], mcp: { expose: true } }).exec(function () { return this.postService.queryInPublic(); }),`,
|
|
262
|
-
` inTag: init({ mcp: { expose: true } }).exec(function () { return this.postService.queryInTag(); }),`,
|
|
263
|
-
` inDraft: init({ ...sharedOption, mcp: { expose: true } }).exec(function () { return this.postService.queryInDraft(); }),`,
|
|
264
|
-
].join("\n"),
|
|
265
|
-
),
|
|
266
|
-
"libs/shared/lib/post/post.dictionary.ts": `export const dictionary = modelDictionary(["en", "ko"]);\n`,
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.mcp.unguarded-exposure");
|
|
270
|
-
|
|
271
|
-
// `inPublic` decided; `inDraft` may have inherited a `guards` from the spread, so it is unreadable, not missing.
|
|
272
|
-
expect(warnings).toHaveLength(1);
|
|
273
|
-
expect(warnings[0]?.message).toContain("inTag");
|
|
274
|
-
expect(warnings[0]?.fix).toContain("guards: [Public]");
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
test("stays silent on a module that exposes nothing to MCP", async () => {
|
|
278
|
-
const root = await makeWorkspace({
|
|
279
|
-
"libs/shared/lib/post/post.signal.ts": signalOf(
|
|
280
|
-
` inPublic: init().exec(function () { return this.postService.queryInPublic(); }),`,
|
|
281
|
-
),
|
|
282
|
-
"libs/shared/lib/post/post.dictionary.ts": `export const dictionary = modelDictionary(["en", "ko"]);\n`,
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
expect(rulesOf(await new AkanQualityScanner().scan(root), "akan.mcp.missing-description")).toHaveLength(0);
|
|
286
|
-
});
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
describe("AkanQualityScanner agent rules", () => {
|
|
290
|
-
const storeOf = (body: string) =>
|
|
291
|
-
[
|
|
292
|
-
`import { store } from "akanjs/store";`,
|
|
293
|
-
`export class PostStore extends store(sig.post, () => ({})) {`,
|
|
294
|
-
body,
|
|
295
|
-
`}`,
|
|
296
|
-
"",
|
|
297
|
-
].join("\n");
|
|
298
|
-
|
|
299
|
-
test("flags only the actions whose endpoint description would be the wrong one", async () => {
|
|
300
|
-
const root = await makeWorkspace({
|
|
301
|
-
"libs/shared/lib/post/post.store.ts": storeOf(
|
|
302
|
-
[
|
|
303
|
-
// Named after the endpoint it calls, so it already reads as that endpoint's `.desc()`.
|
|
304
|
-
` async publishPost(id: string) { await fetch.publishPost(id); }`,
|
|
305
|
-
// Renamed: the store name is the verb a user would say, the endpoint name is the verb the API has.
|
|
306
|
-
` async archive(id: string) { await fetch.archivePost(id); }`,
|
|
307
|
-
// Two endpoints behind one action, so neither one's description covers it.
|
|
308
|
-
` async publishAndTag(id: string) { await fetch.publishPost(id); await fetch.tagPost(id); }`,
|
|
309
|
-
// Never leaves the client, so it is not published and its description would be read by nobody.
|
|
310
|
-
` toggleDraft() { this.set({ draft: !this.get().draft }); }`,
|
|
311
|
-
].join("\n"),
|
|
312
|
-
),
|
|
313
|
-
"libs/shared/lib/post/post.dictionary.ts": `export const dictionary = modelDictionary(["en", "ko"]);\n`,
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.agent.missing-store-description");
|
|
317
|
-
|
|
318
|
-
expect(warnings.map((warning) => warning.message)).toHaveLength(2);
|
|
319
|
-
expect(warnings[0]?.message).toContain("archive");
|
|
320
|
-
expect(warnings[0]?.message).toContain("archivePost()");
|
|
321
|
-
expect(warnings[1]?.message).toContain("publishAndTag");
|
|
322
|
-
expect(warnings[0]?.fix).toContain(".store()");
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
test("accepts an action described on its own store entry or on a same-named endpoint", async () => {
|
|
326
|
-
const root = await makeWorkspace({
|
|
327
|
-
"libs/shared/lib/post/post.store.ts": storeOf(
|
|
328
|
-
[
|
|
329
|
-
` async archive(id: string) { await fetch.archivePost(id); }`,
|
|
330
|
-
` async logout() { await fetch.signoutUser(); }`,
|
|
331
|
-
` async retire(id: string) { await fetch.archivePost(id); }`,
|
|
332
|
-
].join("\n"),
|
|
333
|
-
),
|
|
334
|
-
"libs/shared/lib/post/post.dictionary.ts": [
|
|
335
|
-
`export const dictionary = modelDictionary(["en", "ko"])`,
|
|
336
|
-
` .endpoint((fn) => ({`,
|
|
337
|
-
// Not the endpoint it calls — an entry under the action's own name describes the action.
|
|
338
|
-
` logout: fn(["Log Out", "로그아웃"]).desc(["Ends the session", "세션을 종료합니다"]),`,
|
|
339
|
-
` }))`,
|
|
340
|
-
` .store((t) => ({`,
|
|
341
|
-
` archive: t(["Archive", "보관"]).desc(["Files the post away", "글을 보관합니다"]),`,
|
|
342
|
-
` retire: t(["Retire", "폐기"]),`,
|
|
343
|
-
` }));`,
|
|
344
|
-
"",
|
|
345
|
-
].join("\n"),
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.agent.missing-store-description");
|
|
349
|
-
|
|
350
|
-
// `retire` has an entry but no `.desc()`, which is a label and not a sentence an agent can act on.
|
|
351
|
-
expect(warnings).toHaveLength(1);
|
|
352
|
-
expect(warnings[0]?.message).toContain("retire");
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
test("stays quiet on a store with no custom actions, which is most of them", async () => {
|
|
356
|
-
const root = await makeWorkspace({
|
|
357
|
-
"libs/shared/lib/post/post.store.ts": storeOf(` // action`),
|
|
358
|
-
"libs/shared/lib/post/post.dictionary.ts": `export const dictionary = modelDictionary(["en", "ko"]);\n`,
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
expect(rulesOf(await new AkanQualityScanner().scan(root), "akan.agent.missing-store-description")).toHaveLength(0);
|
|
362
|
-
});
|
|
363
|
-
});
|
|
364
|
-
|
|
365
196
|
describe("AkanQualityScanner layout rules", () => {
|
|
366
197
|
test("flags an unknown app root file but not a facet entrypoint", async () => {
|
|
367
198
|
const root = await makeWorkspace({
|
package/qualityScanner.ts
CHANGED
|
@@ -5,13 +5,11 @@ import { RESERVED_ROUTE_CONFIG_EXPORTS } from "akanjs/common";
|
|
|
5
5
|
import ignore from "ignore";
|
|
6
6
|
import ts from "typescript";
|
|
7
7
|
import { AbstractDoc } from "./abstractDoc";
|
|
8
|
-
import { McpScanner } from "./mcpScanner";
|
|
9
8
|
import { formatSsrBalance, type SsrBalanceEntry, SsrScanner } from "./ssrScanner";
|
|
10
|
-
import { StoreScanner } from "./storeScanner";
|
|
11
9
|
import { appRootAllowedFiles, libFacetRootAllowedFiles } from "./workspaceLayout";
|
|
12
10
|
|
|
13
11
|
type QualitySeverity = "warning";
|
|
14
|
-
type QualityScope = "global" | "file" | "convention" | "layout" | "ssr"
|
|
12
|
+
type QualityScope = "global" | "file" | "convention" | "layout" | "ssr";
|
|
15
13
|
|
|
16
14
|
export interface QualityWarning {
|
|
17
15
|
rule: string;
|
|
@@ -146,12 +144,6 @@ const RULE_FIXES: Record<string, string> = {
|
|
|
146
144
|
"Add a <Model>.Unit.tsx for list/card rendering and a <Model>.View.tsx for the detail surface, then have the Zone delegate to them.",
|
|
147
145
|
"akan.ssr.template-client-state":
|
|
148
146
|
"Bind the field to the store instead: `value={xForm.field}` with `onChange={st.do.setFieldOnX}`.",
|
|
149
|
-
"akan.mcp.missing-description":
|
|
150
|
-
"Add `.desc([en, ko])` to this entry in the module's dictionary — for a slice, either on the slice entry or on the `<model>List<Slice>` endpoint it generates. Describe when to reach for it, not what it is named.",
|
|
151
|
-
"akan.agent.missing-store-description":
|
|
152
|
-
"Add the action to the module dictionary's `.store()` stage with a `.desc([en, ko])` saying what it does for the user — not what the endpoint it calls does. That stage is optional everywhere else: an action named after its endpoint already reads as that endpoint's description.",
|
|
153
|
-
"akan.mcp.unguarded-exposure":
|
|
154
|
-
"Name the guards in the same option object as `mcp`: `init({ guards: [SignedIn], mcp: { expose: true } })`. Write `guards: [Public]` if anonymous reads are the intent — the access is the same, but only one of the two is a decision. The `slice()` call's guards map reaches the root slice and base CRUD, never a named slice.",
|
|
155
147
|
};
|
|
156
148
|
|
|
157
149
|
function getRuleFix(rule: string): string | undefined {
|
|
@@ -182,8 +174,6 @@ export class AkanQualityScanner {
|
|
|
182
174
|
...sourceFiles.flatMap((sourceFile) => this.#scanLayoutQuality(sourceFile)),
|
|
183
175
|
...abstractFiles.flatMap((abstractFile) => this.#scanAbstractQuality(abstractFile)),
|
|
184
176
|
...ssr.warnings,
|
|
185
|
-
...new McpScanner().scan(sourceFiles),
|
|
186
|
-
...new StoreScanner().scan(sourceFiles),
|
|
187
177
|
];
|
|
188
178
|
|
|
189
179
|
return {
|
package/mcpScanner.ts
DELETED
|
@@ -1,217 +0,0 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
|
-
import ts from "typescript";
|
|
3
|
-
import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
|
|
4
|
-
|
|
5
|
-
interface ExposedDeclaration {
|
|
6
|
-
name: string;
|
|
7
|
-
kind: "endpoint" | "slice";
|
|
8
|
-
line: number;
|
|
9
|
-
/** `unknown` when the option object holds a spread, where a `guards` key may arrive from somewhere unreadable. */
|
|
10
|
-
guards: "declared" | "missing" | "unknown";
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Checks the two things about an MCP exposure that source alone can answer: that it carries a description an agent
|
|
15
|
-
* can act on, and that somebody decided who may call it.
|
|
16
|
-
*
|
|
17
|
-
* A tool's name and argument names are the only other thing a model sees, and neither says what the tool is for
|
|
18
|
-
* or when to reach for it. An undescribed tool is not merely undocumented — it is a tool the model will either
|
|
19
|
-
* skip or call wrongly, so this rides with the exposure decision rather than with general dictionary hygiene.
|
|
20
|
-
*
|
|
21
|
-
* Guards are here because the omission is *syntactic*: the `guards` key sits in the same option literal as
|
|
22
|
-
* `mcp: { expose: true }`, and a named slice inherits nothing from the `slice()` call's own guards map. So the
|
|
23
|
-
* shape that publishes an unguarded read without anyone writing it down is visible in the file, with no resolved
|
|
24
|
-
* types needed — which is what makes it worth checking here rather than only in a boot log.
|
|
25
|
-
*
|
|
26
|
-
* The generated CRUD a slice opts in through `mcp: { get: true }` is checked by neither rule: none of those
|
|
27
|
-
* entries has text of its own to leave out, so each borrows the model's — the `.of()` label as a title, the model
|
|
28
|
-
* `.desc()` appended to the framework's "Get X" as a description — and each takes the `slice()` guards map, which
|
|
29
|
-
* is the one place those guards do reach.
|
|
30
|
-
*
|
|
31
|
-
* It reads source, so it finds `mcp: { expose: true }` only where an author writes it as a literal inside the
|
|
32
|
-
* `slice(` / `endpoint(` call — an option hoisted to a `const`, or an `expose: flag`, is invisible to it. Right
|
|
33
|
-
* for a warning that must not fire on something it merely failed to resolve, but it makes a clean scan "nothing
|
|
34
|
-
* obviously wrong" rather than "everything exposed is described and guarded". The complete answer is the boot log:
|
|
35
|
-
* `McpRouter.report()` holds the resolved catalogue and names every published entry with no description and every
|
|
36
|
-
* one with no guards, generated entries included — and the refusals, which turn on a resolved return type and so
|
|
37
|
-
* are the one class this file could never see.
|
|
38
|
-
*/
|
|
39
|
-
export class McpScanner {
|
|
40
|
-
scan(sourceFiles: SourceFileInfo[]): QualityWarning[] {
|
|
41
|
-
const dictionaries = new Map(
|
|
42
|
-
sourceFiles
|
|
43
|
-
.filter((sourceFile) => sourceFile.file.endsWith(".dictionary.ts"))
|
|
44
|
-
.map((sourceFile) => [path.dirname(sourceFile.file), sourceFile]),
|
|
45
|
-
);
|
|
46
|
-
return sourceFiles
|
|
47
|
-
.filter((sourceFile) => sourceFile.file.endsWith(".signal.ts"))
|
|
48
|
-
.flatMap((sourceFile) => this.#scanSignal(sourceFile, dictionaries.get(path.dirname(sourceFile.file))));
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
#scanSignal(signal: SourceFileInfo, dictionary: SourceFileInfo | undefined): QualityWarning[] {
|
|
52
|
-
const exposed = McpScanner.#exposedDeclarations(signal);
|
|
53
|
-
if (!exposed.length) return [];
|
|
54
|
-
const refName = path.basename(signal.file, ".signal.ts").replace(/^_+/, "");
|
|
55
|
-
const described = dictionary ? McpScanner.#describedEntries(dictionary) : new Map<string, Set<string>>();
|
|
56
|
-
return [
|
|
57
|
-
...exposed
|
|
58
|
-
.filter(({ name, kind }) => !McpScanner.#isDescribed(described, refName, name, kind))
|
|
59
|
-
.map(({ name, kind, line }) => ({
|
|
60
|
-
rule: "akan.mcp.missing-description",
|
|
61
|
-
scope: "mcp" as const,
|
|
62
|
-
severity: "warning" as const,
|
|
63
|
-
file: signal.file,
|
|
64
|
-
line,
|
|
65
|
-
message: `MCP-exposed ${kind} "${name}" has no dictionary .desc(); an agent sees its name and nothing else.`,
|
|
66
|
-
})),
|
|
67
|
-
...exposed
|
|
68
|
-
.filter(({ guards }) => guards === "missing")
|
|
69
|
-
.map(({ name, kind, line }) => ({
|
|
70
|
-
rule: "akan.mcp.unguarded-exposure",
|
|
71
|
-
scope: "mcp" as const,
|
|
72
|
-
severity: "warning" as const,
|
|
73
|
-
file: signal.file,
|
|
74
|
-
line,
|
|
75
|
-
message: `MCP-exposed ${kind} "${name}" declares no guards; a slice's guards map never reaches a named slice.`,
|
|
76
|
-
})),
|
|
77
|
-
];
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
static #isDescribed(described: Map<string, Set<string>>, refName: string, name: string, kind: string) {
|
|
81
|
-
if (described.get(kind)?.has(name)) return true;
|
|
82
|
-
// A slice may instead be described through the endpoint it generates, which is how a dictionary that wants
|
|
83
|
-
// separate wording for the list reads (`bannerListInPublic`).
|
|
84
|
-
return kind === "slice" && !!described.get("endpoint")?.has(`${refName}List${McpScanner.#capitalize(name)}`);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Names declared with `mcp: { expose: true }`, keyed by whether they sit in the slice or endpoint builder. */
|
|
88
|
-
static #exposedDeclarations(signal: SourceFileInfo): ExposedDeclaration[] {
|
|
89
|
-
const found: ExposedDeclaration[] = [];
|
|
90
|
-
const visit = (node: ts.Node) => {
|
|
91
|
-
if (McpScanner.#isExposeOption(node)) {
|
|
92
|
-
const declaration = McpScanner.#enclosingDeclaration(node, signal.sourceFile);
|
|
93
|
-
if (declaration) found.push(declaration);
|
|
94
|
-
}
|
|
95
|
-
ts.forEachChild(node, visit);
|
|
96
|
-
};
|
|
97
|
-
visit(signal.sourceFile);
|
|
98
|
-
return found;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
static #isExposeOption(node: ts.Node) {
|
|
102
|
-
if (!ts.isPropertyAssignment(node) || McpScanner.#propertyName(node) !== "mcp") return false;
|
|
103
|
-
if (!ts.isObjectLiteralExpression(node.initializer)) return false;
|
|
104
|
-
return node.initializer.properties.some(
|
|
105
|
-
(property) =>
|
|
106
|
-
ts.isPropertyAssignment(property) &&
|
|
107
|
-
McpScanner.#propertyName(property) === "expose" &&
|
|
108
|
-
property.initializer.kind === ts.SyntaxKind.TrueKeyword,
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* The name is the property holding the builder chain the option sits in — `inCategory: init({ mcp })…` or
|
|
114
|
-
* `echoTitle: builder.query(String, { mcp })…` — and the kind is the factory that property is declared inside.
|
|
115
|
-
* Reading the kind from the factory rather than from the chain's first identifier keeps it right when a slice
|
|
116
|
-
* callback names its parameter something other than `init`.
|
|
117
|
-
*/
|
|
118
|
-
static #enclosingDeclaration(option: ts.Node, sourceFile: ts.SourceFile): ExposedDeclaration | null {
|
|
119
|
-
let declaration: ts.PropertyAssignment | null = null;
|
|
120
|
-
for (let node = option.parent; node; node = node.parent) {
|
|
121
|
-
if (!declaration && ts.isPropertyAssignment(node)) {
|
|
122
|
-
declaration = node;
|
|
123
|
-
continue;
|
|
124
|
-
}
|
|
125
|
-
const kind = McpScanner.#factoryKind(node);
|
|
126
|
-
if (!kind || !declaration) continue;
|
|
127
|
-
const name = McpScanner.#propertyName(declaration);
|
|
128
|
-
if (!name) return null;
|
|
129
|
-
const line = sourceFile.getLineAndCharacterOfPosition(declaration.getStart(sourceFile)).line + 1;
|
|
130
|
-
return { name, kind, line, guards: McpScanner.#guardState(option) };
|
|
131
|
-
}
|
|
132
|
-
return null;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Read off the literal the `mcp` option sits in, which is the same literal `guards` belongs to — `init({ guards,
|
|
137
|
-
* mcp })`, `query(cnst.X, { guards, mcp })`. A spread in there makes the answer unreadable rather than missing,
|
|
138
|
-
* and a warning that fires on what it merely failed to resolve is worse than one that stays quiet.
|
|
139
|
-
*/
|
|
140
|
-
static #guardState(option: ts.Node): ExposedDeclaration["guards"] {
|
|
141
|
-
const options = option.parent;
|
|
142
|
-
if (!ts.isObjectLiteralExpression(options)) return "unknown";
|
|
143
|
-
if (options.properties.some((property) => ts.isSpreadAssignment(property))) return "unknown";
|
|
144
|
-
return options.properties.some(
|
|
145
|
-
(property) => ts.isPropertyAssignment(property) && McpScanner.#propertyName(property) === "guards",
|
|
146
|
-
)
|
|
147
|
-
? "declared"
|
|
148
|
-
: "missing";
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
static #factoryKind(node: ts.Node): ExposedDeclaration["kind"] | null {
|
|
152
|
-
if (!ts.isCallExpression(node) || !ts.isIdentifier(node.expression)) return null;
|
|
153
|
-
const factory = node.expression.text;
|
|
154
|
-
return factory === "slice" || factory === "endpoint" ? factory : null;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/** Entry names that carry a `.desc()`, per dictionary stage. */
|
|
158
|
-
static #describedEntries(dictionary: SourceFileInfo): Map<string, Set<string>> {
|
|
159
|
-
const described = new Map<string, Set<string>>();
|
|
160
|
-
const visit = (node: ts.Node) => {
|
|
161
|
-
const stage = McpScanner.#dictionaryStage(node);
|
|
162
|
-
if (stage) {
|
|
163
|
-
for (const [name, chain] of McpScanner.#stageEntries(node as ts.CallExpression)) {
|
|
164
|
-
if (!chain.has("desc")) continue;
|
|
165
|
-
const names = described.get(stage) ?? new Set<string>();
|
|
166
|
-
names.add(name);
|
|
167
|
-
described.set(stage, names);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
ts.forEachChild(node, visit);
|
|
171
|
-
};
|
|
172
|
-
visit(dictionary.sourceFile);
|
|
173
|
-
return described;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
static #dictionaryStage(node: ts.Node) {
|
|
177
|
-
if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) return null;
|
|
178
|
-
const stage = node.expression.name.text;
|
|
179
|
-
return stage === "endpoint" || stage === "slice" ? stage : null;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
static #stageEntries(stage: ts.CallExpression): Array<[string, Set<string>]> {
|
|
183
|
-
const callback = stage.arguments[0];
|
|
184
|
-
if (!callback || !ts.isArrowFunction(callback)) return [];
|
|
185
|
-
const body = ts.isParenthesizedExpression(callback.body) ? callback.body.expression : callback.body;
|
|
186
|
-
if (!ts.isObjectLiteralExpression(body)) return [];
|
|
187
|
-
return body.properties.flatMap((property) => {
|
|
188
|
-
if (!ts.isPropertyAssignment(property)) return [];
|
|
189
|
-
const name = McpScanner.#propertyName(property);
|
|
190
|
-
return name ? [[name, McpScanner.#chainCalls(property.initializer)] as [string, Set<string>]] : [];
|
|
191
|
-
});
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Only the calls on the entry's own chain. A nested `.arg((t) => ({ x: t([…]).desc([…]) }))` describes an
|
|
196
|
-
* argument, not the entry, so a subtree walk would read every entry as described.
|
|
197
|
-
*/
|
|
198
|
-
static #chainCalls(expression: ts.Expression): Set<string> {
|
|
199
|
-
const calls = new Set<string>();
|
|
200
|
-
let current: ts.Node = expression;
|
|
201
|
-
while (ts.isCallExpression(current) || ts.isPropertyAccessExpression(current)) {
|
|
202
|
-
if (ts.isPropertyAccessExpression(current)) calls.add(current.name.text);
|
|
203
|
-
current = current.expression;
|
|
204
|
-
}
|
|
205
|
-
return calls;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
static #propertyName(property: ts.PropertyAssignment) {
|
|
209
|
-
const { name } = property;
|
|
210
|
-
if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text;
|
|
211
|
-
return null;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
static #capitalize(value: string) {
|
|
215
|
-
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
216
|
-
}
|
|
217
|
-
}
|
package/storeScanner.ts
DELETED
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
|
-
import ts from "typescript";
|
|
3
|
-
import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
|
|
4
|
-
|
|
5
|
-
interface CustomAction {
|
|
6
|
-
name: string;
|
|
7
|
-
line: number;
|
|
8
|
-
/** The endpoints this action calls as `fetch.<name>`. Empty means it never leaves the client. */
|
|
9
|
-
fetched: string[];
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Checks that a store action an agent can reach says what it does, in the one place this codebase lets it.
|
|
14
|
-
*
|
|
15
|
-
* A store is the surface an in-page agent drives — it reads state through `st.use.*` and acts through `st.do.*` —
|
|
16
|
-
* and an action's name and argument types are the only other thing it sees. Unlike a signal, a store has no
|
|
17
|
-
* builder metadata and no room for prose: the house rules ban JSDoc, and every string a person reads goes through
|
|
18
|
-
* `l()`. So the dictionary's `.store()` stage is the only legal channel for the sentence, and this is the check
|
|
19
|
-
* that it exists where it is actually needed.
|
|
20
|
-
*
|
|
21
|
-
* Three kinds of action are deliberately quiet, because a warning nobody should act on teaches people to ignore
|
|
22
|
-
* the rest:
|
|
23
|
-
*
|
|
24
|
-
* - **Generated actions** (`createX`, `setFieldOnX`, `initXInY`, …) are not in the file at all. Their wording is
|
|
25
|
-
* derived from the model's own labels, so there is nothing for an author to write.
|
|
26
|
-
* - **An action that calls no `fetch.*`** stays on the client and is not published, so its description would be
|
|
27
|
-
* read by nobody.
|
|
28
|
-
* - **An action named after the endpoint it calls** already reads as that endpoint's `.desc()`. That is most of
|
|
29
|
-
* them, and not by accident — the naming rule is that `st.do.X` reads the same as `fetch.X`.
|
|
30
|
-
*
|
|
31
|
-
* What is left is the case where inheriting would be *wrong* rather than merely absent: nine `getSummaryListIn*`
|
|
32
|
-
* actions that all call one `summaryListInPeriod`, where the difference between them is the whole point of having
|
|
33
|
-
* nine; or `logout` over `signoutUser`, where the store name is the verb a user would say and the endpoint name is
|
|
34
|
-
* the verb the API has. Those are the ones a person has to write.
|
|
35
|
-
*
|
|
36
|
-
* It reads source, so an action that reaches its endpoint through anything but a literal `fetch.<name>` — a
|
|
37
|
-
* destructured `fetch`, a helper, a computed key — reads as calling none and stays quiet. Right for a warning that
|
|
38
|
-
* must not fire on what it merely failed to resolve.
|
|
39
|
-
*/
|
|
40
|
-
export class StoreScanner {
|
|
41
|
-
scan(sourceFiles: SourceFileInfo[]): QualityWarning[] {
|
|
42
|
-
const dictionaries = new Map(
|
|
43
|
-
sourceFiles
|
|
44
|
-
.filter((sourceFile) => sourceFile.file.endsWith(".dictionary.ts"))
|
|
45
|
-
.map((sourceFile) => [path.dirname(sourceFile.file), sourceFile]),
|
|
46
|
-
);
|
|
47
|
-
return sourceFiles
|
|
48
|
-
.filter((sourceFile) => sourceFile.file.endsWith(".store.ts"))
|
|
49
|
-
.flatMap((sourceFile) => this.#scanStore(sourceFile, dictionaries.get(path.dirname(sourceFile.file))));
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
#scanStore(store: SourceFileInfo, dictionary: SourceFileInfo | undefined): QualityWarning[] {
|
|
53
|
-
const actions = StoreScanner.#customActions(store);
|
|
54
|
-
if (!actions.length) return [];
|
|
55
|
-
const described = dictionary ? StoreScanner.#describedEntries(dictionary) : new Map<string, Set<string>>();
|
|
56
|
-
return actions
|
|
57
|
-
.filter(({ name, fetched }) => fetched.length && !fetched.includes(name))
|
|
58
|
-
.filter(({ name }) => !described.get("store")?.has(name) && !described.get("endpoint")?.has(name))
|
|
59
|
-
.map(({ name, line, fetched }) => ({
|
|
60
|
-
rule: "akan.agent.missing-store-description",
|
|
61
|
-
scope: "agent" as const,
|
|
62
|
-
severity: "warning" as const,
|
|
63
|
-
file: store.file,
|
|
64
|
-
line,
|
|
65
|
-
message: `Store action "${name}" calls ${fetched.map((key) => `${key}()`).join(", ")} under a different name and has no dictionary .store() entry, so an agent reading it has the name and nothing else.`,
|
|
66
|
-
}));
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Methods written in the store class body. Generated actions never appear here, which is why they are exempt. */
|
|
70
|
-
static #customActions(store: SourceFileInfo): CustomAction[] {
|
|
71
|
-
const actions: CustomAction[] = [];
|
|
72
|
-
const visit = (node: ts.Node) => {
|
|
73
|
-
if (ts.isClassDeclaration(node) && StoreScanner.#extendsStore(node)) {
|
|
74
|
-
for (const member of node.members) {
|
|
75
|
-
// A getter computes rather than dispatches, and a static helper is not on `st.do` at all.
|
|
76
|
-
if (!ts.isMethodDeclaration(member)) continue;
|
|
77
|
-
if (member.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword)) continue;
|
|
78
|
-
const name = StoreScanner.#memberName(member);
|
|
79
|
-
if (!name) continue;
|
|
80
|
-
const line = store.sourceFile.getLineAndCharacterOfPosition(member.getStart(store.sourceFile)).line + 1;
|
|
81
|
-
actions.push({ name, line, fetched: StoreScanner.#fetchedEndpoints(member) });
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
ts.forEachChild(node, visit);
|
|
85
|
-
};
|
|
86
|
-
visit(store.sourceFile);
|
|
87
|
-
return actions;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
static #extendsStore(node: ts.ClassDeclaration) {
|
|
91
|
-
return !!node.heritageClauses?.some((clause) =>
|
|
92
|
-
clause.types.some((type) => ts.isCallExpression(type.expression) && StoreScanner.#isStoreCall(type.expression)),
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
static #isStoreCall(expression: ts.CallExpression) {
|
|
97
|
-
return ts.isIdentifier(expression.expression) && expression.expression.text === "store";
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** The `fetch.<name>` calls inside one action, which is what says whether it is reachable past the client. */
|
|
101
|
-
static #fetchedEndpoints(member: ts.MethodDeclaration): string[] {
|
|
102
|
-
const fetched = new Set<string>();
|
|
103
|
-
const visit = (node: ts.Node) => {
|
|
104
|
-
if (
|
|
105
|
-
ts.isCallExpression(node) &&
|
|
106
|
-
ts.isPropertyAccessExpression(node.expression) &&
|
|
107
|
-
ts.isIdentifier(node.expression.expression) &&
|
|
108
|
-
node.expression.expression.text === "fetch"
|
|
109
|
-
)
|
|
110
|
-
fetched.add(node.expression.name.text);
|
|
111
|
-
ts.forEachChild(node, visit);
|
|
112
|
-
};
|
|
113
|
-
visit(member);
|
|
114
|
-
return [...fetched];
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** Entry names that carry a `.desc()`, per dictionary stage. */
|
|
118
|
-
static #describedEntries(dictionary: SourceFileInfo): Map<string, Set<string>> {
|
|
119
|
-
const described = new Map<string, Set<string>>();
|
|
120
|
-
const visit = (node: ts.Node) => {
|
|
121
|
-
const stage = StoreScanner.#dictionaryStage(node);
|
|
122
|
-
if (stage) {
|
|
123
|
-
for (const [name, chain] of StoreScanner.#stageEntries(node as ts.CallExpression)) {
|
|
124
|
-
if (!chain.has("desc")) continue;
|
|
125
|
-
const names = described.get(stage) ?? new Set<string>();
|
|
126
|
-
names.add(name);
|
|
127
|
-
described.set(stage, names);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
ts.forEachChild(node, visit);
|
|
131
|
-
};
|
|
132
|
-
visit(dictionary.sourceFile);
|
|
133
|
-
return described;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
static #dictionaryStage(node: ts.Node) {
|
|
137
|
-
if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) return null;
|
|
138
|
-
const stage = node.expression.name.text;
|
|
139
|
-
return stage === "store" || stage === "endpoint" ? stage : null;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
static #stageEntries(stage: ts.CallExpression): Array<[string, Set<string>]> {
|
|
143
|
-
const callback = stage.arguments[0];
|
|
144
|
-
if (!callback || !ts.isArrowFunction(callback)) return [];
|
|
145
|
-
const body = ts.isParenthesizedExpression(callback.body) ? callback.body.expression : callback.body;
|
|
146
|
-
if (!ts.isObjectLiteralExpression(body)) return [];
|
|
147
|
-
return body.properties.flatMap((property) => {
|
|
148
|
-
if (!ts.isPropertyAssignment(property)) return [];
|
|
149
|
-
const name = StoreScanner.#memberName(property);
|
|
150
|
-
return name ? [[name, StoreScanner.#chainCalls(property.initializer)] as [string, Set<string>]] : [];
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Only the calls on the entry's own chain. A nested `.arg((t) => ({ x: t([…]).desc([…]) }))` describes an
|
|
156
|
-
* argument, not the entry, so a subtree walk would read every entry as described.
|
|
157
|
-
*/
|
|
158
|
-
static #chainCalls(expression: ts.Expression): Set<string> {
|
|
159
|
-
const calls = new Set<string>();
|
|
160
|
-
let current: ts.Node = expression;
|
|
161
|
-
while (ts.isCallExpression(current) || ts.isPropertyAccessExpression(current)) {
|
|
162
|
-
if (ts.isPropertyAccessExpression(current)) calls.add(current.name.text);
|
|
163
|
-
current = current.expression;
|
|
164
|
-
}
|
|
165
|
-
return calls;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
static #memberName(member: ts.MethodDeclaration | ts.PropertyAssignment) {
|
|
169
|
-
const { name } = member;
|
|
170
|
-
if (!name || (!ts.isIdentifier(name) && !ts.isStringLiteral(name))) return null;
|
|
171
|
-
return name.text;
|
|
172
|
-
}
|
|
173
|
-
}
|