@akanjs/devkit 3.0.0-alpha.3 → 3.0.0-alpha.5

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/akanContext.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  workflowRunArtifactPath,
15
15
  workflowSyncDir,
16
16
  } from "./workflow";
17
+ import { appRootAllowedDirs, appRootAllowedFiles, isScannedAppRootEntry } from "./workspaceLayout";
17
18
 
18
19
  export type AkanContextFormat = "json" | "markdown";
19
20
  export type AkanModuleKind = "domain" | "service" | "scalar";
@@ -313,36 +314,6 @@ const moduleShapeFiles = (module: AkanModuleContext) => {
313
314
  const constantFieldNames = (content: string) =>
314
315
  [...content.matchAll(/\b([A-Za-z_$][\w$]*)\s*:\s*field\(/g)].map((match) => match[1]).filter(Boolean);
315
316
 
316
- const appRootAllowFiles = new Set([
317
- // 스코프 에이전트 가이드 — scan(write) 이 유지 (agentsIndex.ts); scanInfo.ts 의 appRootAllowedFiles 와 동기
318
- "AGENTS.md",
319
- "CLAUDE.md",
320
- "akan.app.json",
321
- "akan.config.ts",
322
- "capacitor.config.ts",
323
- "client.ts",
324
- "main.ts",
325
- "package.json",
326
- "server.ts",
327
- "tsconfig.json",
328
- ]);
329
-
330
- const appRootAllowDirs = new Set([
331
- ".akan",
332
- "android",
333
- "common",
334
- "env",
335
- "ios",
336
- "lib",
337
- "page",
338
- "private",
339
- "public",
340
- "script",
341
- "srvkit",
342
- "ui",
343
- "webkit",
344
- ]);
345
-
346
317
  const safeReadDir = async (dirPath: string) => {
347
318
  try {
348
319
  return (await readdir(dirPath, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
@@ -671,7 +642,8 @@ export class AkanContextAnalyzer {
671
642
  for (const app of context.apps) {
672
643
  const appPath = path.join(workspace.workspaceRoot, app.path);
673
644
  for (const entry of await safeReadDir(appPath)) {
674
- const allowed = entry.isDirectory() ? appRootAllowDirs.has(entry.name) : appRootAllowFiles.has(entry.name);
645
+ if (!isScannedAppRootEntry(entry.name)) continue;
646
+ const allowed = entry.isDirectory() ? appRootAllowedDirs.has(entry.name) : appRootAllowedFiles.has(entry.name);
675
647
  if (!allowed) {
676
648
  const action = repairAction(
677
649
  "module-shape",
@@ -112,7 +112,7 @@ export class ApplicationBuildRunner {
112
112
  () => precompressArtifacts(this.#app),
113
113
  (result) =>
114
114
  result.files > 0
115
- ? `${result.files} files, ${ApplicationBuildRunner.formatBytes(result.inputBytes)} -> ${ApplicationBuildRunner.formatBytes(result.outputBytes)}`
115
+ ? `${result.files} files, ${ApplicationBuildRunner.formatBytes(result.inputBytes)} -> gzip ${ApplicationBuildRunner.formatBytes(result.outputBytes)} / br ${ApplicationBuildRunner.formatBytes(result.brotliBytes)}`
116
116
  : "no files",
117
117
  phaseOptions,
118
118
  );
@@ -47,11 +47,24 @@ export class AllRoutesBuilder {
47
47
  this.#app.verbose(`[build-all] discovered ${seedIndex.entries.length} routes`);
48
48
  this.#discovery = await GraphClientEntryDiscovery.create(this.#app);
49
49
 
50
+ // Discovery first, bundling second. Chunk splitting only dedupes within one `Bun.build`, so a
51
+ // dependency shared by entries from different routes was emitted once per route that reached it.
52
+ // Discovery is cached and does no bundling, so collecting every entry up front costs almost nothing.
53
+ const allEntries: string[] = [];
54
+ const seen = new Set<string>();
50
55
  for (const entry of seedIndex.entries) {
51
56
  const seeds = Array.from(new Set([...seedIndex.globalLayoutFiles, ...entry.seeds]));
52
- const delta = await this.#buildRoute(entry.routeId, seeds);
53
- this.#mergeRoute(entry.routeId, delta);
57
+ for (const discovered of await this.#discovery.discover(seeds)) {
58
+ if (seen.has(discovered)) continue;
59
+ seen.add(discovered);
60
+ allEntries.push(discovered);
61
+ }
62
+ this.#routeIds.push(entry.routeId);
54
63
  }
64
+ this.#app.verbose(`[build-all] ${allEntries.length} client entries across ${this.#routeIds.length} routes`);
65
+
66
+ const delta = await this.#buildEntries(allEntries);
67
+ this.#mergeDelta(delta);
55
68
  this.#merged.knownEntries = Array.from(this.#knownSet);
56
69
 
57
70
  const manifest: RoutesManifest = {
@@ -76,28 +89,34 @@ export class AllRoutesBuilder {
76
89
  return { manifest, manifestPath, seedIndex };
77
90
  }
78
91
 
79
- async #buildRoute(routeId: string, seeds: string[]): Promise<BuildRouteClientResult> {
92
+ async #buildEntries(entries: string[]): Promise<BuildRouteClientResult> {
80
93
  if (!this.#discovery) throw new Error("[build-all] client entry discovery is not initialized");
94
+ if (entries.length === 0)
95
+ return {
96
+ manifestDelta: {},
97
+ ssrManifestDelta: { moduleLoading: null, moduleMap: {} },
98
+ newEntries: [],
99
+ clientDeps: [],
100
+ };
81
101
  const started = Date.now();
82
102
  const delta = await new RouteClientBuilder({
83
103
  app: this.#app,
84
- routeId,
85
- seeds,
104
+ seeds: [],
105
+ entries,
86
106
  artifact: this.#artifact,
87
107
  knownEntries: this.#knownSet,
88
108
  discovery: this.#discovery,
89
109
  command: this.#command,
90
110
  }).build();
91
- this.#app.verbose(`[build-all] ${routeId} +${delta.newEntries.length} entries (${Date.now() - started}ms)`);
111
+ this.#app.verbose(`[build-all] bundled ${delta.newEntries.length} entries (${Date.now() - started}ms)`);
92
112
  return delta;
93
113
  }
94
114
 
95
- #mergeRoute(routeId: string, delta: BuildRouteClientResult): void {
115
+ #mergeDelta(delta: BuildRouteClientResult): void {
96
116
  for (const [key, row] of Object.entries(delta.manifestDelta)) this.#merged.clientManifest[key] = row;
97
117
  for (const [url, byName] of Object.entries(delta.ssrManifestDelta.moduleMap)) {
98
118
  this.#merged.ssrManifest.moduleMap[url] = byName;
99
119
  }
100
120
  for (const abs of delta.newEntries) this.#knownSet.add(abs);
101
- this.#routeIds.push(routeId);
102
121
  }
103
122
  }
@@ -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
- const roots = [path.join(app.dist.cwdPath, ".akan/artifact/client")];
16
- const result: PrecompressArtifactsResult = { files: 0, inputBytes: 0, outputBytes: 0 };
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} gzip sidecars (${formatBytes(result.inputBytes)} -> ${formatBytes(
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 gz = Bun.gzipSync(toArrayBuffer(bytes));
36
- await Bun.write(`${filePath}.gz`, gz);
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 discovery = this.#discovery ?? (await GraphClientEntryDiscovery.create(this.#app));
75
- const discovered = await discovery.discover(this.#seeds);
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.0.0-alpha.3",
3
+ "version": "3.0.0-alpha.5",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -44,7 +44,7 @@
44
44
  "@langchain/openai": "^1.4.6",
45
45
  "@tailwindcss/node": "^4.3.0",
46
46
  "@trapezedev/project": "^7.1.4",
47
- "akanjs": "3.0.0-alpha.3",
47
+ "akanjs": "3.0.0-alpha.5",
48
48
  "chalk": "^5.6.2",
49
49
  "commander": "^14.0.3",
50
50
  "dayjs": "^1.11.20",
@@ -183,3 +183,17 @@ describe("AkanQualityScanner ssr rules", () => {
183
183
  expect(ssrBalance[2]).toMatchObject({ scope: "workspace", serverMass: 3, clientMass: 1 });
184
184
  });
185
185
  });
186
+
187
+ describe("AkanQualityScanner layout rules", () => {
188
+ test("flags an unknown app root file but not a facet entrypoint", async () => {
189
+ const root = await makeWorkspace({
190
+ "apps/demo/client.ts": "export const client = 1;\n",
191
+ "apps/demo/helper.ts": "export const helper = 1;\n",
192
+ });
193
+
194
+ const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.layout.app-root-file");
195
+
196
+ expect(warnings).toHaveLength(1);
197
+ expect(warnings[0]?.file).toBe("apps/demo/helper.ts");
198
+ });
199
+ });
package/qualityScanner.ts CHANGED
@@ -5,6 +5,7 @@ import ignore from "ignore";
5
5
  import ts from "typescript";
6
6
  import { AbstractDoc } from "./abstractDoc";
7
7
  import { formatSsrBalance, type SsrBalanceEntry, SsrScanner } from "./ssrScanner";
8
+ import { appRootAllowedFiles, libFacetRootAllowedFiles } from "./workspaceLayout";
8
9
 
9
10
  type QualitySeverity = "warning";
10
11
  type QualityScope = "global" | "file" | "convention" | "layout" | "ssr";
@@ -90,29 +91,6 @@ const SUGGESTED_RULES = [
90
91
  "Avoid large mixed-purpose class files; class export files should import helpers from neighboring utility files instead of declaring them inline.",
91
92
  ];
92
93
 
93
- const APP_ROOT_FILES = new Set([
94
- "akan.app.json",
95
- "akan.config.ts",
96
- "capacitor.config.ts",
97
- "client.ts",
98
- "main.ts",
99
- "package.json",
100
- "server.ts",
101
- "tsconfig.json",
102
- ]);
103
-
104
- const LIB_ROOT_FILES = new Set([
105
- "cnst.ts",
106
- "db.ts",
107
- "dict.ts",
108
- "option.ts",
109
- "sig.ts",
110
- "srv.ts",
111
- "st.ts",
112
- "useClient.ts",
113
- "useServer.ts",
114
- ]);
115
-
116
94
  const CONVENTION_SUFFIXES = [
117
95
  ".constant.ts",
118
96
  ".dictionary.ts",
@@ -449,7 +427,7 @@ export class AkanQualityScanner {
449
427
  #scanLayoutQuality(sourceFile: SourceFileInfo): QualityWarning[] {
450
428
  const segments = sourceFile.file.split("/");
451
429
  const warnings: QualityWarning[] = [];
452
- if (segments[0] === "apps" && segments.length === 3 && !APP_ROOT_FILES.has(segments[2])) {
430
+ if (segments[0] === "apps" && segments.length === 3 && !appRootAllowedFiles.has(segments[2])) {
453
431
  warnings.push({
454
432
  rule: "akan.layout.app-root-file",
455
433
  scope: "layout",
@@ -460,7 +438,7 @@ export class AkanQualityScanner {
460
438
  }
461
439
 
462
440
  const libRootFile = getLibRootFile(sourceFile.file);
463
- if (libRootFile && !LIB_ROOT_FILES.has(libRootFile)) {
441
+ if (libRootFile && !libFacetRootAllowedFiles.has(libRootFile)) {
464
442
  warnings.push({
465
443
  rule: "akan.layout.lib-root-file",
466
444
  scope: "layout",
package/scanInfo.ts CHANGED
@@ -11,6 +11,7 @@ import type {
11
11
  } from "./akanConfig";
12
12
 
13
13
  import { AppExecutor, LibExecutor, PkgExecutor, WorkspaceExecutor } from "./executors";
14
+ import { appRootAllowedDirs, appRootAllowedFiles, libFacetRootAllowedFiles } from "./workspaceLayout";
14
15
 
15
16
  const scalarFileTypes = ["constant", "dictionary", "document", "template", "unit", "util", "view", "zone"] as const;
16
17
  type ScalarFileType = (typeof scalarFileTypes)[number];
@@ -43,49 +44,7 @@ type DatabaseFileType = (typeof databaseFileTypes)[number];
43
44
 
44
45
  type ModuleKind = "database" | "service" | "scalar";
45
46
 
46
- const appRootAllowedFiles = new Set([
47
- // 스코프 에이전트 가이드 — scan(write) 이 유지하는 색인 + 마커 밖 hand-written 내용 (agentsIndex.ts)
48
- "AGENTS.md",
49
- "CLAUDE.md",
50
- "akan.app.json",
51
- "akan.config.ts",
52
- "capacitor.config.ts",
53
- "client.ts",
54
- "main.ts",
55
- "package.json",
56
- "server.ts",
57
- "tsconfig.json",
58
- "tsconfig.tsbuildinfo",
59
- ]);
60
47
  const generatedRootCapacitorConfigFiles = ["capacitor.config.js", "capacitor.config.json"] as const;
61
- const appRootAllowedDirs = new Set([
62
- ".akan",
63
- "android",
64
- "env",
65
- "ios",
66
- "lib",
67
- "mobile",
68
- "page",
69
- "private",
70
- "public",
71
- "script",
72
- "ui",
73
- "srvkit",
74
- "webkit",
75
- "common",
76
- "secrets",
77
- ]);
78
- const libRootAllowedFiles = new Set([
79
- "cnst.ts",
80
- "db.ts",
81
- "dict.ts",
82
- "option.ts",
83
- "sig.ts",
84
- "srv.ts",
85
- "st.ts",
86
- "useClient.ts",
87
- "useServer.ts",
88
- ]);
89
48
  const internalLibDirs = new Set(["__lib", "__scalar"]);
90
49
  const moduleNonUiFileTypes = {
91
50
  database: new Set(["constant", "dictionary", "document", "service", "signal", "store"]),
@@ -107,7 +66,7 @@ const createDependencyScanner = async (exec: AppExecutor | LibExecutor | PkgExec
107
66
 
108
67
  const isAllowedTestFile = (filename: string) => testFilePattern.test(filename);
109
68
  const isAllowedLibRootFile = (filename: string) =>
110
- libRootAllowedFiles.has(filename) || rootSignalTestFilePattern.test(filename);
69
+ libFacetRootAllowedFiles.has(filename) || rootSignalTestFilePattern.test(filename);
111
70
  const getScanPath = (exec: AppExecutor | LibExecutor, relativePath: string) =>
112
71
  path.posix.join(`${exec.type}s`, exec.name, relativePath.split(path.sep).join("/"));
113
72
  async function clearGeneratedRootCapacitorConfigs(exec: AppExecutor | LibExecutor) {
@@ -0,0 +1,26 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { appRootAllowedDirs, appRootAllowedFiles, isScannedAppRootEntry } from "./workspaceLayout";
3
+
4
+ describe("app root layout allowlist", () => {
5
+ test("admits the scoped agent guides sync writes into every app", () => {
6
+ expect(appRootAllowedFiles.has("AGENTS.md")).toBe(true);
7
+ expect(appRootAllowedFiles.has("CLAUDE.md")).toBe(true);
8
+ });
9
+
10
+ test("admits every documented app root folder", () => {
11
+ for (const dirname of ["mobile", "plugin", "secrets", "srvkit", "webkit"]) {
12
+ expect(appRootAllowedDirs.has(dirname)).toBe(true);
13
+ }
14
+ });
15
+
16
+ test("rejects an app root entry no facet owns", () => {
17
+ expect(appRootAllowedFiles.has("helper.ts")).toBe(false);
18
+ expect(appRootAllowedDirs.has("base")).toBe(false);
19
+ });
20
+
21
+ test("skips dotfile artifacts the sync glob never sees, but keeps .akan", () => {
22
+ expect(isScannedAppRootEntry(".DS_Store")).toBe(false);
23
+ expect(isScannedAppRootEntry(".akan")).toBe(true);
24
+ expect(isScannedAppRootEntry("lib")).toBe(true);
25
+ });
26
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * App/lib 루트 레이아웃 허용 목록 — 단일 소스.
3
+ *
4
+ * 같은 규칙을 scanInfo(`akan sync`, hard error) · akanContext(`akan doctor`, diagnostic) ·
5
+ * qualityScanner(`akan quality scan`, warning) 세 곳이 각자 복사해 두면서 실제로 어긋났다
6
+ * (스코프 AGENTS.md/CLAUDE.md 는 sync 만 허용, `plugin` 은 문서에만, `secrets` 는 doctor 만 거부).
7
+ * 규칙을 추가할 때는 이 파일만 고치고, 루트 AGENTS.md 와 `.cursor/rules/akan-scan-conventions.mdc`
8
+ * 의 목록도 같이 갱신한다.
9
+ */
10
+
11
+ export const appRootAllowedFiles = new Set([
12
+ // 스코프 에이전트 가이드 — scan(write) 이 유지하는 색인 + 마커 밖 hand-written 내용 (agentsIndex.ts)
13
+ "AGENTS.md",
14
+ "CLAUDE.md",
15
+ "akan.app.json",
16
+ "akan.config.ts",
17
+ "capacitor.config.ts",
18
+ "client.ts",
19
+ "main.ts",
20
+ "package.json",
21
+ "server.ts",
22
+ "tsconfig.json",
23
+ "tsconfig.tsbuildinfo",
24
+ ]);
25
+
26
+ export const appRootAllowedDirs = new Set([
27
+ ".akan",
28
+ "android",
29
+ "common",
30
+ "env",
31
+ "ios",
32
+ "lib",
33
+ "mobile",
34
+ "page",
35
+ "plugin",
36
+ "private",
37
+ "public",
38
+ "script",
39
+ "secrets",
40
+ "srvkit",
41
+ "ui",
42
+ "webkit",
43
+ ]);
44
+
45
+ export const libFacetRootAllowedFiles = new Set([
46
+ "cnst.ts",
47
+ "db.ts",
48
+ "dict.ts",
49
+ "option.ts",
50
+ "sig.ts",
51
+ "srv.ts",
52
+ "st.ts",
53
+ "useClient.ts",
54
+ "useServer.ts",
55
+ ]);
56
+
57
+ /**
58
+ * scanSync 는 앱 루트를 `Bun.Glob("*")` 로 읽어 dotfile 을 아예 보지 못한다. 디렉터리를 직접 읽는
59
+ * doctor 가 그 차이만큼 `.DS_Store` 같은 툴 산출물을 에러로 올리므로 같은 기준으로 걸러낸다.
60
+ */
61
+ export const isScannedAppRootEntry = (name: string) => !name.startsWith(".") || appRootAllowedDirs.has(name);