@akanjs/devkit 3.0.0-alpha.1 → 3.0.0-alpha.11
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/agentsIndex.ts +26 -0
- package/akanContext.ts +43 -31
- package/applicationBuildRunner.ts +1 -1
- package/biome.base.json +313 -0
- package/biomeBase.ts +9 -0
- package/capacitorApp.test.ts +8 -0
- package/capacitorApp.ts +112 -18
- package/executors.test.ts +3 -1
- package/executors.ts +2 -1
- package/frontendBuild/allRoutesBuilder.ts +27 -8
- package/frontendBuild/cssCandidateCache.test.ts +38 -0
- package/frontendBuild/cssCandidateCache.ts +15 -3
- package/frontendBuild/cssCompiler.ts +60 -2
- package/frontendBuild/cssImportResolver.ts +8 -7
- package/frontendBuild/frontendBuild.test.ts +54 -0
- package/frontendBuild/precompressArtifacts.ts +35 -7
- package/frontendBuild/routeClientBuilder.ts +14 -2
- package/lint/no-bang-comment-in-client.grit +20 -0
- package/lint/no-import-client-in-server.grit +48 -0
- package/lint/no-import-server-in-client.grit +45 -0
- package/lint/no-return-in-store-action.grit +35 -0
- package/linter.ts +17 -12
- package/mcpScanner.ts +217 -0
- package/package.json +3 -2
- package/qualityScanner.test.ts +192 -0
- package/qualityScanner.ts +16 -45
- package/repoIdentity.ts +42 -0
- package/routeSourceValidator.test.ts +41 -0
- package/routeSourceValidator.ts +2 -44
- package/scanInfo.ts +2 -43
- package/storeScanner.ts +173 -0
- package/workspaceLayout.test.ts +26 -0
- package/workspaceLayout.ts +60 -0
|
@@ -1,26 +1,41 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import zlib from "node:zlib";
|
|
3
4
|
import type { App } from "../commandDecorators";
|
|
4
5
|
|
|
5
6
|
const COMPRESSIBLE_EXTS = new Set([".css", ".html", ".js", ".json", ".svg"]);
|
|
6
7
|
const MIN_COMPRESS_BYTES = 1024;
|
|
8
|
+
/**
|
|
9
|
+
* Quality 11 is roughly 100x slower than gzip, so it is spent only where it pays: there is one CSS asset
|
|
10
|
+
* per basePath and it is the largest single file the app ships, worth ~20% over gzip for ~0.2s. Raising the
|
|
11
|
+
* several hundred JS chunks from 9 to 11 costs ~12s of build time to save a few hundred KB, so they stay at 9.
|
|
12
|
+
*/
|
|
13
|
+
const BROTLI_QUALITY_BY_EXT = { ".css": 11 } as const;
|
|
14
|
+
const DEFAULT_BROTLI_QUALITY = 9;
|
|
15
|
+
const GZIP_LEVEL = 9;
|
|
7
16
|
|
|
8
17
|
export interface PrecompressArtifactsResult {
|
|
9
18
|
files: number;
|
|
10
19
|
inputBytes: number;
|
|
11
20
|
outputBytes: number;
|
|
21
|
+
brotliBytes: number;
|
|
12
22
|
}
|
|
13
23
|
|
|
14
24
|
export async function precompressArtifacts(app: App): Promise<PrecompressArtifactsResult> {
|
|
15
|
-
|
|
16
|
-
|
|
25
|
+
//* styles too: WebRouter serves both prefixes through the same sidecar-aware #fileResponse, and the
|
|
26
|
+
//* one CSS asset per basePath is the largest uncompressed payload the app ships.
|
|
27
|
+
const roots = [
|
|
28
|
+
path.join(app.dist.cwdPath, ".akan/artifact/client"),
|
|
29
|
+
path.join(app.dist.cwdPath, ".akan/artifact/styles"),
|
|
30
|
+
];
|
|
31
|
+
const result: PrecompressArtifactsResult = { files: 0, inputBytes: 0, outputBytes: 0, brotliBytes: 0 };
|
|
17
32
|
|
|
18
33
|
await Promise.all(roots.map((root) => precompressRoot(root, result)));
|
|
19
34
|
if (result.files > 0) {
|
|
20
35
|
app.verbose(
|
|
21
|
-
`[precompress] wrote ${result.files}
|
|
36
|
+
`[precompress] wrote ${result.files} sidecars (${formatBytes(result.inputBytes)} -> gzip ${formatBytes(
|
|
22
37
|
result.outputBytes,
|
|
23
|
-
)})`,
|
|
38
|
+
)} / br ${formatBytes(result.brotliBytes)})`,
|
|
24
39
|
);
|
|
25
40
|
}
|
|
26
41
|
return result;
|
|
@@ -32,16 +47,29 @@ async function precompressRoot(root: string, result: PrecompressArtifactsResult)
|
|
|
32
47
|
for await (const filePath of glob.scan({ cwd: root, absolute: true })) {
|
|
33
48
|
if (!(await shouldPrecompress(filePath))) continue;
|
|
34
49
|
const bytes = await Bun.file(filePath).bytes();
|
|
35
|
-
const
|
|
36
|
-
|
|
50
|
+
const buffer = toArrayBuffer(bytes);
|
|
51
|
+
const gz = Bun.gzipSync(buffer, { level: GZIP_LEVEL });
|
|
52
|
+
const br = brotliCompress(buffer, path.extname(filePath).toLowerCase());
|
|
53
|
+
await Promise.all([Bun.write(`${filePath}.gz`, gz), Bun.write(`${filePath}.br`, br)]);
|
|
37
54
|
result.files += 1;
|
|
38
55
|
result.inputBytes += bytes.byteLength;
|
|
39
56
|
result.outputBytes += gz.byteLength;
|
|
57
|
+
result.brotliBytes += br.byteLength;
|
|
40
58
|
}
|
|
41
59
|
}
|
|
42
60
|
|
|
61
|
+
function brotliCompress(buffer: ArrayBuffer, ext: string): Buffer {
|
|
62
|
+
const quality = BROTLI_QUALITY_BY_EXT[ext as keyof typeof BROTLI_QUALITY_BY_EXT] ?? DEFAULT_BROTLI_QUALITY;
|
|
63
|
+
return zlib.brotliCompressSync(buffer, {
|
|
64
|
+
params: {
|
|
65
|
+
[zlib.constants.BROTLI_PARAM_QUALITY]: quality,
|
|
66
|
+
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: buffer.byteLength,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
43
71
|
async function shouldPrecompress(filePath: string): Promise<boolean> {
|
|
44
|
-
if (filePath.endsWith(".gz")) return false;
|
|
72
|
+
if (filePath.endsWith(".gz") || filePath.endsWith(".br")) return false;
|
|
45
73
|
if (!COMPRESSIBLE_EXTS.has(path.extname(filePath).toLowerCase())) return false;
|
|
46
74
|
const file = Bun.file(filePath);
|
|
47
75
|
if (!(await file.exists())) return false;
|
|
@@ -33,6 +33,15 @@ export interface BuildRouteClientOptions {
|
|
|
33
33
|
routeId?: string;
|
|
34
34
|
command?: "build" | "start";
|
|
35
35
|
discovery?: ClientEntryDiscovery;
|
|
36
|
+
/**
|
|
37
|
+
* Client entries resolved by the caller, which skips discovery and bundles exactly this list.
|
|
38
|
+
*
|
|
39
|
+
* `AllRoutesBuilder` passes every route's entries at once so they share one `Bun.build`. Chunk
|
|
40
|
+
* splitting is scoped to a single invocation, so a dependency reachable from entries spread across
|
|
41
|
+
* several invocations is emitted once per invocation — mermaid landed in `apps/akan` four times that
|
|
42
|
+
* way. Dev keeps one build per route and leaves this unset.
|
|
43
|
+
*/
|
|
44
|
+
entries?: string[];
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
export interface BuildRouteClientResult {
|
|
@@ -61,6 +70,7 @@ export class RouteClientBuilder {
|
|
|
61
70
|
#knownEntries: Set<string>;
|
|
62
71
|
#command: "build" | "start";
|
|
63
72
|
#discovery?: ClientEntryDiscovery;
|
|
73
|
+
#entries?: string[];
|
|
64
74
|
|
|
65
75
|
constructor(options: BuildRouteClientOptions) {
|
|
66
76
|
this.#app = options.app;
|
|
@@ -68,11 +78,13 @@ export class RouteClientBuilder {
|
|
|
68
78
|
this.#knownEntries = options.knownEntries ?? new Set<string>();
|
|
69
79
|
this.#command = options.command ?? "start";
|
|
70
80
|
this.#discovery = options.discovery;
|
|
81
|
+
this.#entries = options.entries;
|
|
71
82
|
}
|
|
72
83
|
|
|
73
84
|
async build(): Promise<BuildRouteClientResult> {
|
|
74
|
-
const
|
|
75
|
-
|
|
85
|
+
const discovered =
|
|
86
|
+
this.#entries ??
|
|
87
|
+
(await (this.#discovery ?? (await GraphClientEntryDiscovery.create(this.#app))).discover(this.#seeds));
|
|
76
88
|
const entries = discovered.filter((e) => !this.#knownEntries.has(e));
|
|
77
89
|
if (entries.length === 0) return this.#emptyResult(discovered);
|
|
78
90
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// Bun's bundler classifies `//!` and `/*!` as legal comments (the `@license` / `@preserve` class) and keeps
|
|
5
|
+
// them through `minify: true`, so a `//!` marker in browser-reachable code ships verbatim to every visitor.
|
|
6
|
+
// `legalComments` is not a Bun.build option, so the source is the only place to stop it.
|
|
7
|
+
// The two alternatives anchor the marker to line start or to whitespace after code, which keeps a literal
|
|
8
|
+
// like `'https://host//!path'` from tripping the rule. A marker on the file's very first line is trivia that
|
|
9
|
+
// Biome does not expose to the pattern, so it is the one case this rule cannot see.
|
|
10
|
+
JsModule() as $mod where {
|
|
11
|
+
or {
|
|
12
|
+
$mod <: r"(?m)(?s).*^[ \t]*//!.*",
|
|
13
|
+
$mod <: r"(?s).*[^\s/][ \t]+//!.*"
|
|
14
|
+
},
|
|
15
|
+
register_diagnostic(
|
|
16
|
+
span = $mod,
|
|
17
|
+
message = "The `//!` marker survives minification (Bun keeps it as a legal comment) and ships to the browser. Use `// FIXME:` or `// TODO:` in client-reachable code; keep `//!` for server, srvkit, and CLI files.",
|
|
18
|
+
severity = "error"
|
|
19
|
+
)
|
|
20
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// Server files (*.document.ts, *.dictionary.ts, *.service.ts, *.signal.ts, srvkit/) and shared files
|
|
5
|
+
// (common/, *.constant.ts) run in Bun with no DOM and no bundler, and every CLI command, worker, and
|
|
6
|
+
// migration that loads a service loads whatever the service imports. Reaching into the client graph
|
|
7
|
+
// pulls React, the store, and the browser globals they touch into that process, and it runs the
|
|
8
|
+
// dependency backwards: the client entrypoint is built on top of cnst and sig, not the other way round.
|
|
9
|
+
//
|
|
10
|
+
// `import type` is erased before bundling and stays legal. A mixed value-and-type import is not exempt.
|
|
11
|
+
or {
|
|
12
|
+
JsModuleSource() as $source where {
|
|
13
|
+
$source <: within JsImport() as $import,
|
|
14
|
+
not $import <: r"import\s+type[\s\S]*",
|
|
15
|
+
$source <: or {
|
|
16
|
+
r"\".*\.store\"",
|
|
17
|
+
r"\".*\.(?:Template|Unit|Util|View|Zone)\""
|
|
18
|
+
},
|
|
19
|
+
register_diagnostic(
|
|
20
|
+
span = $source,
|
|
21
|
+
message = "Client module. A server or shared file must not import a *.store or a module component (*.Template, *.Unit, *.Util, *.View, *.Zone) — server code reaches the model through cnst and db, never through client state or JSX.",
|
|
22
|
+
severity = "error"
|
|
23
|
+
)
|
|
24
|
+
},
|
|
25
|
+
JsModuleSource() as $source where {
|
|
26
|
+
$source <: within JsImport() as $import,
|
|
27
|
+
not $import <: r"import\s+type[\s\S]*",
|
|
28
|
+
$source <: or {
|
|
29
|
+
r"\"(?:.*/)?(?:ui|webkit)(?:/.*)?\"",
|
|
30
|
+
r"\".*/client(?:/.*)?\""
|
|
31
|
+
},
|
|
32
|
+
register_diagnostic(
|
|
33
|
+
span = $source,
|
|
34
|
+
message = "Client entrypoint. A server or shared file must not import ui/, webkit/, or a package client entrypoint such as '@libs/<lib>/client' or 'akanjs/client' — import the server entrypoint or a common/ helper instead.",
|
|
35
|
+
severity = "error"
|
|
36
|
+
)
|
|
37
|
+
},
|
|
38
|
+
JsModuleSource() as $source where {
|
|
39
|
+
$source <: within JsImport() as $import,
|
|
40
|
+
not $import <: r"import\s+type[\s\S]*",
|
|
41
|
+
$source <: r"\"(?:.*/)?(?:st|store|useClient)\"",
|
|
42
|
+
register_diagnostic(
|
|
43
|
+
span = $source,
|
|
44
|
+
message = "Client barrel. A server or shared file must not import st, store, or useClient — server code holds no client state, and Err comes from dict on the server.",
|
|
45
|
+
severity = "error"
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// Client files (ui/, webkit/, page/, *.store.ts, every .tsx) and shared files (common/, *.constant.ts)
|
|
5
|
+
// are compiled into the browser bundle, so a single value import of a server module drags its whole
|
|
6
|
+
// graph along — the database driver, node:crypto, a secret resolved from process.env. The boundary has
|
|
7
|
+
// to hold at the import statement, because by the call site the module is already bundled.
|
|
8
|
+
//
|
|
9
|
+
// `import type` is erased before bundling and stays legal, so a shared file may still name a server-side
|
|
10
|
+
// type. A mixed value-and-type import is not exempt: it emits a real edge.
|
|
11
|
+
or {
|
|
12
|
+
JsModuleSource() as $source where {
|
|
13
|
+
$source <: within JsImport() as $import,
|
|
14
|
+
not $import <: r"import\s+type[\s\S]*",
|
|
15
|
+
$source <: r"\".*\.(?:document|dictionary|service|signal)\"",
|
|
16
|
+
register_diagnostic(
|
|
17
|
+
span = $source,
|
|
18
|
+
message = "Server module. A client or shared file must not import a *.document, *.dictionary, *.service, or *.signal file — take the model from the package client entrypoint ('@libs/<lib>/client') or from its *.constant instead. Write 'import type' if only the type is needed.",
|
|
19
|
+
severity = "error"
|
|
20
|
+
)
|
|
21
|
+
},
|
|
22
|
+
JsModuleSource() as $source where {
|
|
23
|
+
$source <: within JsImport() as $import,
|
|
24
|
+
not $import <: r"import\s+type[\s\S]*",
|
|
25
|
+
$source <: or {
|
|
26
|
+
r"\"(?:.*/)?srvkit(?:/.*)?\"",
|
|
27
|
+
r"\".*/server(?:/.*)?\""
|
|
28
|
+
},
|
|
29
|
+
register_diagnostic(
|
|
30
|
+
span = $source,
|
|
31
|
+
message = "Server entrypoint. A client or shared file must not import srvkit/ or a package server entrypoint such as '@apps/<app>/server' or 'akanjs/server' — import the matching client entrypoint instead.",
|
|
32
|
+
severity = "error"
|
|
33
|
+
)
|
|
34
|
+
},
|
|
35
|
+
JsModuleSource() as $source where {
|
|
36
|
+
$source <: within JsImport() as $import,
|
|
37
|
+
not $import <: r"import\s+type[\s\S]*",
|
|
38
|
+
$source <: r"\"(?:.*/)?(?:db|srv|sig|dict|option|useServer)\"",
|
|
39
|
+
register_diagnostic(
|
|
40
|
+
span = $source,
|
|
41
|
+
message = "Server barrel. A client or shared file must not import db, srv, sig, dict, option, or useServer — read models from cnst, state from st, and Err from the package client entrypoint.",
|
|
42
|
+
severity = "error"
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// Every prototype method of a `store(...)` class is registered as an action
|
|
5
|
+
// (StoreRegistry.register) and reaches callers only as `st.do.<action>`, which re-wraps it
|
|
6
|
+
// (StoreInstance.#extendAccessors) and is typed void by `VoidActions` in
|
|
7
|
+
// pkgs/akanjs/store/types.ts. A returned value is therefore unreachable from every call
|
|
8
|
+
// site — it reads like a contract while being dead code. Write the result into state with
|
|
9
|
+
// `this.set({ ... })` instead.
|
|
10
|
+
//
|
|
11
|
+
// Deliberately not flagged, because none of these are actions: a bare `return;` guard, a
|
|
12
|
+
// `return` belonging to a nested callback, a getter, a `static` helper, and a class-property
|
|
13
|
+
// arrow (an instance field, so `register` never sees it).
|
|
14
|
+
`return $value;` as $return where {
|
|
15
|
+
not $value <: r"",
|
|
16
|
+
$return <: within JsClassDeclaration() as $storeClass where {
|
|
17
|
+
$storeClass <: contains JsExtendsClause() as $extends where {
|
|
18
|
+
$extends <: r"extends\s+store\([\s\S]*"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
$return <: within JsMethodClassMember() as $action where {
|
|
22
|
+
not $action <: r"static[\s\S]*"
|
|
23
|
+
},
|
|
24
|
+
not $return <: within JsArrowFunctionExpression(),
|
|
25
|
+
not $return <: within JsFunctionExpression(),
|
|
26
|
+
not $return <: within JsFunctionDeclaration(),
|
|
27
|
+
not $return <: within JsMethodObjectMember(),
|
|
28
|
+
not $return <: within JsGetterObjectMember(),
|
|
29
|
+
not $return <: within JsSetterObjectMember(),
|
|
30
|
+
register_diagnostic(
|
|
31
|
+
span = $return,
|
|
32
|
+
message = "Store actions dispatch as void — st.do.<action>() never exposes this value. Write the result into state with this.set({ ... }) and drop the returned value; a bare 'return;' guard clause is fine.",
|
|
33
|
+
severity = "error"
|
|
34
|
+
)
|
|
35
|
+
}
|
package/linter.ts
CHANGED
|
@@ -58,19 +58,31 @@ interface LintResponse {
|
|
|
58
58
|
warnings: LintMessage[];
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/** Biome reads `biome.json` first and `biome.jsonc` second; only the latter may carry comments. */
|
|
62
|
+
const BIOME_CONFIG_FILES = ["biome.json", "biome.jsonc"] as const;
|
|
63
|
+
|
|
61
64
|
export class Linter {
|
|
62
65
|
lintRoot: string;
|
|
66
|
+
configPath: string;
|
|
63
67
|
#biomeBin: string;
|
|
64
68
|
|
|
65
69
|
constructor(cwdPath: string) {
|
|
66
70
|
this.lintRoot = this.#findBiomeRootPath(cwdPath);
|
|
71
|
+
this.configPath = Linter.#configPathIn(this.lintRoot) ?? path.join(this.lintRoot, "biome.json");
|
|
67
72
|
const localBiomeBin = path.join(this.lintRoot, "node_modules/.bin/biome");
|
|
68
73
|
this.#biomeBin = existsSync(localBiomeBin) ? localBiomeBin : "biome";
|
|
69
74
|
}
|
|
70
75
|
|
|
76
|
+
static #configPathIn(dir: string): string | null {
|
|
77
|
+
for (const fileName of BIOME_CONFIG_FILES) {
|
|
78
|
+
const configPath = path.join(dir, fileName);
|
|
79
|
+
if (existsSync(configPath)) return configPath;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
71
84
|
#findBiomeRootPath(dir: string): string {
|
|
72
|
-
|
|
73
|
-
if (existsSync(configPath)) return dir;
|
|
85
|
+
if (Linter.#configPathIn(dir)) return dir;
|
|
74
86
|
const parentDir = path.dirname(dir);
|
|
75
87
|
if (parentDir === dir) throw new Error(`biome.json not found from ${dir}`);
|
|
76
88
|
return this.#findBiomeRootPath(parentDir);
|
|
@@ -193,7 +205,7 @@ export class Linter {
|
|
|
193
205
|
"--max-diagnostics=none",
|
|
194
206
|
"--no-errors-on-unmatched",
|
|
195
207
|
"--config-path",
|
|
196
|
-
|
|
208
|
+
this.configPath,
|
|
197
209
|
this.#toBiomePath(filePath),
|
|
198
210
|
]);
|
|
199
211
|
const report = this.#parseBiomeReport(stdout || stderr);
|
|
@@ -390,14 +402,7 @@ export class Linter {
|
|
|
390
402
|
|
|
391
403
|
const source = readFileSync(resolvedFilePath, "utf8");
|
|
392
404
|
const { stdout } = await this.#runBiome(
|
|
393
|
-
[
|
|
394
|
-
"check",
|
|
395
|
-
"--write",
|
|
396
|
-
"--config-path",
|
|
397
|
-
path.join(this.lintRoot, "biome.json"),
|
|
398
|
-
"--stdin-file-path",
|
|
399
|
-
this.#toBiomePath(resolvedFilePath),
|
|
400
|
-
],
|
|
405
|
+
["check", "--write", "--config-path", this.configPath, "--stdin-file-path", this.#toBiomePath(resolvedFilePath)],
|
|
401
406
|
source,
|
|
402
407
|
);
|
|
403
408
|
const lintResult = await this.lintFile(resolvedFilePath);
|
|
@@ -412,7 +417,7 @@ export class Linter {
|
|
|
412
417
|
async getConfigForFile(filePath: string): Promise<unknown> {
|
|
413
418
|
const resolvedFilePath = this.#resolveFilePath(filePath);
|
|
414
419
|
if (!existsSync(resolvedFilePath)) throw new Error(`File not found: ${filePath}`);
|
|
415
|
-
return JSON.parse(readFileSync(
|
|
420
|
+
return JSON.parse(readFileSync(this.configPath, "utf8")) as unknown;
|
|
416
421
|
}
|
|
417
422
|
|
|
418
423
|
/**
|
package/mcpScanner.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
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/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.11",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"default": "./index.ts"
|
|
24
24
|
},
|
|
25
25
|
"./package.json": "./package.json",
|
|
26
|
+
"./biome.base.json": "./biome.base.json",
|
|
26
27
|
"./akanApp": "./akanApp/index.ts",
|
|
27
28
|
"./akanConfig": "./akanConfig/index.ts",
|
|
28
29
|
"./artifact": "./artifact/index.ts",
|
|
@@ -44,7 +45,7 @@
|
|
|
44
45
|
"@langchain/openai": "^1.4.6",
|
|
45
46
|
"@tailwindcss/node": "^4.3.0",
|
|
46
47
|
"@trapezedev/project": "^7.1.4",
|
|
47
|
-
"akanjs": "3.0.0-alpha.
|
|
48
|
+
"akanjs": "3.0.0-alpha.11",
|
|
48
49
|
"chalk": "^5.6.2",
|
|
49
50
|
"commander": "^14.0.3",
|
|
50
51
|
"dayjs": "^1.11.20",
|