@akanjs/devkit 3.0.0-alpha.10 → 3.0.0-alpha.12

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/biome.base.json CHANGED
@@ -25,7 +25,7 @@
25
25
  "noArrayIndexKey": "off",
26
26
  "noShadowRestrictedNames": "off",
27
27
  "noUnnecessaryConditions": {
28
- "level": "error"
28
+ "level": "warn"
29
29
  }
30
30
  },
31
31
  "correctness": {
@@ -117,7 +117,9 @@
117
117
  "libs/*/client.ts",
118
118
  "libs/*/server.ts",
119
119
  "libs/*/index.ts",
120
- "libs/*/lib/*/index.tsx"
120
+ "libs/*/lib/*/index.tsx",
121
+ "apps/*/lib/__lib/**",
122
+ "libs/*/lib/__lib/**"
121
123
  ],
122
124
  "linter": { "enabled": false },
123
125
  "formatter": { "enabled": false },
@@ -140,26 +142,36 @@
140
142
  "plugins": ["./node_modules/@akanjs/devkit/lint/no-throw-raw-error.grit"]
141
143
  },
142
144
  {
143
- "includes": ["**/page/**/*.ts", "**/page/**/*.tsx", "**/*.Unit.tsx", "**/*.View.tsx"],
145
+ "includes": [
146
+ "{apps,libs}/**/page/**/*.ts",
147
+ "{apps,libs}/**/page/**/*.tsx",
148
+ "{apps,libs}/**/*.Unit.tsx",
149
+ "{apps,libs}/**/*.View.tsx"
150
+ ],
144
151
  "plugins": [
145
152
  "./node_modules/@akanjs/devkit/lint/no-import-client-functions.grit",
146
153
  "./node_modules/@akanjs/devkit/lint/no-use-client-in-server.grit"
147
154
  ]
148
155
  },
149
156
  {
150
- "includes": ["**/page/**/*.ts", "**/page/**/*.tsx"],
157
+ "includes": ["{apps,libs}/**/page/**/*.ts", "{apps,libs}/**/page/**/*.tsx"],
151
158
  "plugins": ["./node_modules/@akanjs/devkit/lint/non-scalar-props-restricted.grit"]
152
159
  },
153
160
  {
154
- "includes": ["**/*.constant.ts", "**/*.document.ts", "**/*.service.ts", "**/*.store.ts"],
161
+ "includes": [
162
+ "{apps,libs}/**/*.constant.ts",
163
+ "{apps,libs}/**/*.document.ts",
164
+ "{apps,libs}/**/*.service.ts",
165
+ "{apps,libs}/**/*.store.ts"
166
+ ],
155
167
  "plugins": ["./node_modules/@akanjs/devkit/lint/no-js-private-class-method.grit"]
156
168
  },
157
169
  {
158
- "includes": ["**/*.store.ts"],
170
+ "includes": ["{apps,libs}/**/*.store.ts"],
159
171
  "plugins": ["./node_modules/@akanjs/devkit/lint/no-return-in-store-action.grit"]
160
172
  },
161
173
  {
162
- "includes": ["**/*.signal.ts"],
174
+ "includes": ["{apps,libs}/**/*.signal.ts"],
163
175
  "plugins": ["./node_modules/@akanjs/devkit/lint/no-redeclare-predefined-endpoint.grit"]
164
176
  },
165
177
  {
@@ -186,46 +198,46 @@
186
198
  },
187
199
  {
188
200
  "includes": [
189
- "**/*.constant.ts",
190
- "**/*.dictionary.ts",
191
- "**/*.document.ts",
192
- "**/*.service.ts",
193
- "**/*.signal.ts",
194
- "**/*.store.ts",
195
- "**/*.Template.tsx",
196
- "**/*.Unit.tsx",
197
- "**/*.Util.tsx",
198
- "**/*.View.tsx",
199
- "**/*.Zone.tsx"
201
+ "{apps,libs}/**/*.constant.ts",
202
+ "{apps,libs}/**/*.dictionary.ts",
203
+ "{apps,libs}/**/*.document.ts",
204
+ "{apps,libs}/**/*.service.ts",
205
+ "{apps,libs}/**/*.signal.ts",
206
+ "{apps,libs}/**/*.store.ts",
207
+ "{apps,libs}/**/*.Template.tsx",
208
+ "{apps,libs}/**/*.Unit.tsx",
209
+ "{apps,libs}/**/*.Util.tsx",
210
+ "{apps,libs}/**/*.View.tsx",
211
+ "{apps,libs}/**/*.Zone.tsx"
200
212
  ],
201
213
  "plugins": ["./node_modules/@akanjs/devkit/lint/no-deep-internal-import.grit"]
202
214
  },
203
215
  {
204
216
  "includes": [
205
- "**/page/**/*.ts",
206
- "**/page/**/*.tsx",
207
- "**/index.ts",
208
- "**/index.tsx",
209
- "**/cnst.ts",
210
- "**/db.ts",
211
- "**/dict.ts",
212
- "**/option.ts",
213
- "**/sig.ts",
214
- "**/srv.ts",
215
- "**/st.ts",
216
- "**/*.constant.ts",
217
- "**/*.dictionary.ts",
218
- "**/*.document.ts",
219
- "**/*.service.ts",
220
- "**/*.signal.ts",
221
- "**/*.signal.test.ts",
222
- "**/*.service.test.ts",
223
- "**/*.store.ts",
224
- "**/*.Template.tsx",
225
- "**/*.Unit.tsx",
226
- "**/*.Util.tsx",
227
- "**/*.View.tsx",
228
- "**/*.Zone.tsx"
217
+ "{apps,libs}/**/page/**/*.ts",
218
+ "{apps,libs}/**/page/**/*.tsx",
219
+ "{apps,libs}/**/index.ts",
220
+ "{apps,libs}/**/index.tsx",
221
+ "{apps,libs}/**/cnst.ts",
222
+ "{apps,libs}/**/db.ts",
223
+ "{apps,libs}/**/dict.ts",
224
+ "{apps,libs}/**/option.ts",
225
+ "{apps,libs}/**/sig.ts",
226
+ "{apps,libs}/**/srv.ts",
227
+ "{apps,libs}/**/st.ts",
228
+ "{apps,libs}/**/*.constant.ts",
229
+ "{apps,libs}/**/*.dictionary.ts",
230
+ "{apps,libs}/**/*.document.ts",
231
+ "{apps,libs}/**/*.service.ts",
232
+ "{apps,libs}/**/*.signal.ts",
233
+ "{apps,libs}/**/*.signal.test.ts",
234
+ "{apps,libs}/**/*.service.test.ts",
235
+ "{apps,libs}/**/*.store.ts",
236
+ "{apps,libs}/**/*.Template.tsx",
237
+ "{apps,libs}/**/*.Unit.tsx",
238
+ "{apps,libs}/**/*.Util.tsx",
239
+ "{apps,libs}/**/*.View.tsx",
240
+ "{apps,libs}/**/*.Zone.tsx"
229
241
  ],
230
242
  "plugins": [
231
243
  "./node_modules/@akanjs/devkit/lint/no-import-external-library.grit",
@@ -46,6 +46,10 @@ export class CssCompiler {
46
46
  #fileExistsCache = new Map<string, Promise<boolean>>();
47
47
  #resolvedFileCache = new Map<string, Promise<string | null>>();
48
48
  #resolvedSpecifierCache = new Map<string, Promise<string | null>>();
49
+ /** Every stylesheet this compile reached, entry points and `@import` targets alike. */
50
+ #discoveredCssPaths = new Set<string>();
51
+ /** `@import` targets of the compile in progress, by path, so the output can be checked against them. */
52
+ #importedStylesheets = new Map<string, string>();
49
53
 
50
54
  #fileExists(absPath: string): Promise<boolean> {
51
55
  let cached = this.#fileExistsCache.get(absPath);
@@ -76,13 +80,16 @@ export class CssCompiler {
76
80
  }
77
81
  async getCss({ refresh }: { refresh?: boolean } = {}) {
78
82
  if (this.#cssText !== null && !refresh) return this.#cssText;
83
+ this.#discoveredCssPaths.clear();
79
84
  const { cssPaths, sourcePaths } = await this.discoverCssAndSources({ refresh });
80
85
  this.#cssText = await this.compileCss(cssPaths, sourcePaths);
86
+ await this.#warnUnreachableStylesheets();
81
87
  return this.#cssText;
82
88
  }
83
89
 
84
90
  async getCssByBasePath({ refresh }: { refresh?: boolean } = {}): Promise<Record<string, string>> {
85
91
  if (this.#cssTextByBasePath !== null && !refresh) return this.#cssTextByBasePath;
92
+ this.#discoveredCssPaths.clear();
86
93
  const akanConfig = await this.#app.getConfig({ refresh });
87
94
  const pageKeys = await this.#app.getPageKeys({ refresh });
88
95
  const basePaths = [...akanConfig.basePaths];
@@ -111,9 +118,26 @@ export class CssCompiler {
111
118
  }),
112
119
  ]);
113
120
  this.#cssTextByBasePath = Object.fromEntries(cssEntries);
121
+ await this.#warnUnreachableStylesheets();
114
122
  return this.#cssTextByBasePath;
115
123
  }
116
124
 
125
+ /**
126
+ * A stylesheet under `page/` reaches the build only by being imported from a route source. One that nothing
127
+ * imports compiles to nothing and reports success, which is indistinguishable from an empty theme — so say it
128
+ * out loud once per compile rather than leaving it to be noticed as unstyled elements in the browser.
129
+ */
130
+ async #warnUnreachableStylesheets() {
131
+ const pageDir = path.join(this.#app.cwdPath, "page");
132
+ const glob = new Bun.Glob("**/*.css");
133
+ for await (const cssPath of glob.scan({ cwd: pageDir, absolute: true })) {
134
+ // `(libs)` is a link farm: the same file is discovered under its real path in `libs/`, never this one.
135
+ if (cssPath.includes(`${path.sep}(libs)${path.sep}`)) continue;
136
+ if (this.#discoveredCssPaths.has(cssPath)) continue;
137
+ this.#logger.warn(`css ${path.relative(this.#app.cwdPath, cssPath)} is imported by no route and never compiled`);
138
+ }
139
+ }
140
+
117
141
  async discoverCss({ refresh }: { refresh?: boolean } = {}): Promise<string[]> {
118
142
  const { cssPaths } = await this.discoverCssAndSources({ refresh });
119
143
  return cssPaths;
@@ -180,11 +204,38 @@ export class CssCompiler {
180
204
  }
181
205
  }
182
206
 
183
- return { cssPaths: [...cssFiles], sourcePaths: [...sourceFiles] };
207
+ const tokenPaths = await this.#libTokenStylesheets(sourceFiles);
208
+ const cssPaths = [...new Set([...tokenPaths, ...cssFiles])];
209
+ for (const cssPath of cssPaths) this.#discoveredCssPaths.add(cssPath);
210
+ return { cssPaths, sourcePaths: [...sourceFiles] };
211
+ }
212
+
213
+ /**
214
+ * `libs/<lib>/ui/tokens.css` of every lib the page graph reached, so a lib can own the fixed colours its own
215
+ * components need instead of each consuming app re-declaring them. Ordered ahead of the app's stylesheets:
216
+ * the app is the last word on any variable both declare.
217
+ */
218
+ async #libTokenStylesheets(sourceFiles: Set<string>): Promise<string[]> {
219
+ const libsRoot = path.join(this.#app.workspace.workspaceRoot, "libs");
220
+ const libNames = new Set<string>();
221
+ for (const filePath of sourceFiles) {
222
+ const relPath = path.relative(libsRoot, filePath);
223
+ if (relPath.startsWith("..") || path.isAbsolute(relPath)) continue;
224
+ const [libName] = relPath.split(path.sep);
225
+ if (libName) libNames.add(libName);
226
+ }
227
+ const tokenPaths = await Promise.all(
228
+ [...libNames].sort().map(async (libName) => {
229
+ const tokensPath = path.join(libsRoot, libName, "ui/tokens.css");
230
+ return (await this.#fileExists(tokensPath)) ? tokensPath : null;
231
+ }),
232
+ );
233
+ return tokenPaths.filter((tokensPath): tokensPath is string => !!tokensPath);
184
234
  }
185
235
  async compileCss(cssPaths: string[], sourcePaths: string[]): Promise<string> {
186
236
  if (cssPaths.length === 0) return "";
187
237
 
238
+ this.#importedStylesheets.clear();
188
239
  const compileStarted = Date.now();
189
240
  const compilers = await Promise.all(
190
241
  cssPaths.map(async (cssPath) => {
@@ -217,17 +268,47 @@ export class CssCompiler {
217
268
  this.#logger.verbose(
218
269
  `css compiled paths=${cssPaths.length} candidates=${candidates.length} in ${Date.now() - compileStarted}ms`,
219
270
  );
220
- return parts.join("\n");
271
+ const css = parts.join("\n");
272
+ this.#warnDroppedImports(css);
273
+ return css;
274
+ }
275
+
276
+ /**
277
+ * An `@import` can resolve, be read, and still contribute nothing — the one failure the existence check
278
+ * cannot see, and the one that reads as an unstyled component rather than as an error. Custom properties
279
+ * declared outside `@theme` are the tell: those pass through verbatim, so a stylesheet whose every
280
+ * declaration is missing from the build did not make it in.
281
+ */
282
+ #warnDroppedImports(css: string) {
283
+ for (const [cssPath, content] of this.#importedStylesheets) {
284
+ const names = declaredCustomProperties(content);
285
+ if (names.length === 0 || names.some((name) => css.includes(`${name}:`))) continue;
286
+ this.#logger.warn(
287
+ `css @import ${cssPath} was loaded but none of its ${names.length} declaration(s) reached the compiled CSS`,
288
+ );
289
+ }
221
290
  }
222
291
 
223
292
  async #loadStylesheet(id: string, fromBase: string) {
224
293
  const p = await this.#resolveCssImport(id, fromBase);
294
+ this.#discoveredCssPaths.add(p);
225
295
  const content = await Bun.file(p).text();
296
+ this.#importedStylesheets.set(p, content);
297
+ this.#logger.verbose(`css import "${id}" from ${fromBase} -> ${p} (${content.length} bytes)`);
226
298
  return { path: p, base: path.dirname(p), content };
227
299
  }
228
300
 
301
+ /**
302
+ * Every specifier is verified here, path-shaped ones included. An `@import` the pipeline cannot resolve is
303
+ * a build error and never a no-op: the vocabulary closure means a component whose token declaration failed
304
+ * to load renders unstyled, which nothing downstream can distinguish from a design choice.
305
+ */
229
306
  async #resolveCssImport(id: string, fromBase: string): Promise<string> {
230
- if (id.startsWith(".") || id.startsWith("/")) return path.resolve(fromBase, id);
307
+ if (id.startsWith(".") || id.startsWith("/")) {
308
+ const filePath = path.resolve(fromBase, id);
309
+ if (await this.#fileExists(filePath)) return filePath;
310
+ throw new Error(`[css] failed to resolve stylesheet import "${id}" from ${fromBase} (no file at ${filePath})`);
311
+ }
231
312
  const resolver = await this.#getCssImportResolver();
232
313
  const resolved = await resolver.resolve(id, fromBase);
233
314
  if (resolved) return resolved;
@@ -330,6 +411,17 @@ export function isIgnoredNodeModuleSource(filePath: string): boolean {
330
411
  return NODE_MODULES_RE.test(filePath) && !AKANJS_NODE_MODULE_RE.test(filePath);
331
412
  }
332
413
 
414
+ /**
415
+ * `@theme` blocks are stripped first: those variables are emitted only when a utility uses one, so their
416
+ * absence from a build says nothing about whether the stylesheet arrived.
417
+ */
418
+ export function declaredCustomProperties(css: string): string[] {
419
+ const withoutThemeBlocks = css.replace(/@theme[^{]*\{[^}]*\}/g, "");
420
+ return [...new Set([...withoutThemeBlocks.matchAll(/(?:^|[\s;{])(--[\w-]+)\s*:/g)].map(([, name]) => name))].filter(
421
+ (name): name is string => !!name,
422
+ );
423
+ }
424
+
333
425
  function getPageKeyBasePath(pageKey: string, basePaths: string[]): string | null {
334
426
  const normalized = pageKey.split(path.sep).join("/").replace(/^\.\//, "");
335
427
  const segments = normalized.split("/");
@@ -106,14 +106,15 @@ export class CssImportResolver {
106
106
  const pkg = await Bun.file(pkgPath).json();
107
107
  const subpath = id === pkgName ? "." : `.${id.slice(pkgName.length)}`;
108
108
  const exportValue = pkg.exports?.[subpath];
109
- const styleEntry =
110
- (typeof exportValue === "string"
109
+ const exportedEntry =
110
+ typeof exportValue === "string"
111
111
  ? exportValue
112
- : exportValue?.style || exportValue?.import || exportValue?.default) ||
113
- pkg.exports?.["."]?.style ||
114
- pkg.style ||
115
- "index.css";
116
- return await this.#firstExisting(path.resolve(pkgDir, styleEntry));
112
+ : exportValue?.style || exportValue?.import || exportValue?.default;
113
+ if (exportedEntry) return await this.#firstExisting(path.resolve(pkgDir, exportedEntry));
114
+ //* A subpath names a file inside the package, so it resolves literally. Falling back to the package's own
115
+ //* style entry here would load a different stylesheet than the author asked for and report success.
116
+ if (subpath !== ".") return await this.#firstExisting(path.resolve(pkgDir, subpath));
117
+ return await this.#firstExisting(path.resolve(pkgDir, pkg.exports?.["."]?.style || pkg.style || "index.css"));
117
118
  } catch {
118
119
  return null;
119
120
  }
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import type { RoutesManifest } from "akanjs/server";
7
7
  import { CsrArtifactBuilder } from "./csrArtifactBuilder";
8
- import { CssCompiler, isIgnoredNodeModuleSource } from "./cssCompiler";
8
+ import { CssCompiler, declaredCustomProperties, isIgnoredNodeModuleSource } from "./cssCompiler";
9
9
  import { CssImportResolver } from "./cssImportResolver";
10
10
  import { DevChangePlanner } from "./devChangePlanner";
11
11
  import { DevGeneratedIndexSync } from "./devGeneratedIndexSync";
@@ -391,6 +391,20 @@ describe("CssImportResolver", () => {
391
391
  expect(await resolver.resolve("@libs/ui/missing", root)).toBeNull();
392
392
  });
393
393
 
394
+ test("never substitutes the package stylesheet for a subpath that does not exist", async () => {
395
+ const root = await makeTempRoot();
396
+ await write(
397
+ path.join(root, "node_modules/vendor/package.json"),
398
+ JSON.stringify({ name: "vendor", style: "index.css" }),
399
+ );
400
+ await write(path.join(root, "node_modules/vendor/index.css"), ".vendor {}\n");
401
+
402
+ const resolver = new CssImportResolver(root, {});
403
+
404
+ expect(await resolver.resolve("vendor", root)).toBe(path.join(root, "node_modules/vendor/index.css"));
405
+ expect(await resolver.resolve("vendor/ui/tokens.css", root)).toBeNull();
406
+ });
407
+
394
408
  test("resolves css from single-package Akan workspace subpaths", async () => {
395
409
  const root = await makeTempRoot();
396
410
  await write(path.join(root, "pkgs/akanjs/ui/styles.css"), "body {}\n");
@@ -428,4 +442,55 @@ describe("CssCompiler", () => {
428
442
 
429
443
  expect(css).toContain(".text-fuchsia-500");
430
444
  });
445
+
446
+ test("reads declarations that prove a stylesheet arrived, ignoring theme variables that may not", () => {
447
+ expect(declaredCustomProperties(":root { --kakao: #fee500; --naver: #1ec800; }")).toEqual(["--kakao", "--naver"]);
448
+ expect(declaredCustomProperties("@theme inline {\n --color-brand: var(--brand);\n}\n")).toEqual([]);
449
+ expect(declaredCustomProperties("@theme { --color-x: initial; }\n:root { --brand: #111; }")).toEqual(["--brand"]);
450
+ expect(declaredCustomProperties(".a { color: var(--kakao); }")).toEqual([]);
451
+ expect(declaredCustomProperties(":root{--a:#111}\n@media (min-width:1px){:root{--a:#222;--b:#333}}")).toEqual([
452
+ "--a",
453
+ "--b",
454
+ ]);
455
+ });
456
+
457
+ test("fails loudly on a stylesheet import that resolves to nothing", async () => {
458
+ const root = await makeTempRoot();
459
+ const cssPath = path.join(root, "apps/demo/page/styles.css");
460
+ await write(cssPath, '@import "../../../libs/shared/ui/tokens.css";\n');
461
+
462
+ const compiler = new CssCompiler({
463
+ workspace: { workspaceRoot: root },
464
+ cwdPath: path.join(root, "apps/demo"),
465
+ getTsConfig: async () => ({ compilerOptions: { paths: {} } }),
466
+ } as never);
467
+
468
+ await expect(compiler.compileCss([cssPath], [])).rejects.toThrow(
469
+ /failed to resolve stylesheet import "\.\.\/\.\.\/\.\.\/libs\/shared\/ui\/tokens\.css"/,
470
+ );
471
+ });
472
+
473
+ test("compiles lib-owned tokens ahead of the app stylesheets that may override them", async () => {
474
+ const root = await makeTempRoot();
475
+ const appDir = path.join(root, "apps/demo");
476
+ await write(
477
+ path.join(appDir, "page/_index.tsx"),
478
+ 'import "./styles.css";\nimport { Card } from "@libs/shared/ui";\nexport default () => <Card />;\n',
479
+ );
480
+ await write(path.join(appDir, "page/styles.css"), ":root { --brand: #111111; }\n");
481
+ await write(path.join(root, "libs/shared/ui/index.ts"), "export const Card = () => null;\n");
482
+ await write(path.join(root, "libs/shared/ui/tokens.css"), ":root { --kakao: #fee500; }\n");
483
+ await write(path.join(root, "libs/unused/ui/tokens.css"), ":root { --unused: #000000; }\n");
484
+
485
+ const compiler = new CssCompiler({
486
+ workspace: { workspaceRoot: root },
487
+ cwdPath: appDir,
488
+ getPageKeys: async () => ["./_index.tsx"],
489
+ getConfig: async () => ({ barrelImports: [] }),
490
+ getTsConfig: async () => ({ compilerOptions: { paths: { "@libs/*": ["./libs/*"] } } }),
491
+ } as never);
492
+ const { cssPaths } = await compiler.discoverCssAndSources();
493
+
494
+ expect(cssPaths).toEqual([path.join(root, "libs/shared/ui/tokens.css"), path.join(appDir, "page/styles.css")]);
495
+ });
431
496
  });
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
- const configPath = path.join(dir, "biome.json");
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
- path.join(this.lintRoot, "biome.json"),
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(path.join(this.lintRoot, "biome.json"), "utf8")) as unknown;
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.10",
3
+ "version": "3.0.0-alpha.12",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -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.10",
48
+ "akanjs": "3.0.0-alpha.12",
49
49
  "chalk": "^5.6.2",
50
50
  "commander": "^14.0.3",
51
51
  "dayjs": "^1.11.20",
@@ -184,6 +184,184 @@ describe("AkanQualityScanner ssr rules", () => {
184
184
  });
185
185
  });
186
186
 
187
+ const signalOf = (entries: string) =>
188
+ [
189
+ `import { endpoint, slice } from "akanjs/signal";`,
190
+ `export class PostSlice extends slice(srv.post, { guards: {}, mcp: { get: true } }, (init) => ({`,
191
+ entries,
192
+ `})) {}`,
193
+ "",
194
+ ].join("\n");
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
+
187
365
  describe("AkanQualityScanner layout rules", () => {
188
366
  test("flags an unknown app root file but not a facet entrypoint", async () => {
189
367
  const root = await makeWorkspace({
package/qualityScanner.ts CHANGED
@@ -5,11 +5,13 @@ 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";
8
9
  import { formatSsrBalance, type SsrBalanceEntry, SsrScanner } from "./ssrScanner";
10
+ import { StoreScanner } from "./storeScanner";
9
11
  import { appRootAllowedFiles, libFacetRootAllowedFiles } from "./workspaceLayout";
10
12
 
11
13
  type QualitySeverity = "warning";
12
- type QualityScope = "global" | "file" | "convention" | "layout" | "ssr";
14
+ type QualityScope = "global" | "file" | "convention" | "layout" | "ssr" | "mcp" | "agent";
13
15
 
14
16
  export interface QualityWarning {
15
17
  rule: string;
@@ -144,6 +146,12 @@ const RULE_FIXES: Record<string, string> = {
144
146
  "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.",
145
147
  "akan.ssr.template-client-state":
146
148
  "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.",
147
155
  };
148
156
 
149
157
  function getRuleFix(rule: string): string | undefined {
@@ -174,6 +182,8 @@ export class AkanQualityScanner {
174
182
  ...sourceFiles.flatMap((sourceFile) => this.#scanLayoutQuality(sourceFile)),
175
183
  ...abstractFiles.flatMap((abstractFile) => this.#scanAbstractQuality(abstractFile)),
176
184
  ...ssr.warnings,
185
+ ...new McpScanner().scan(sourceFiles),
186
+ ...new StoreScanner().scan(sourceFiles),
177
187
  ];
178
188
 
179
189
  return {
@@ -0,0 +1,173 @@
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
+ }