@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 +3 -31
- package/applicationBuildRunner.ts +1 -1
- package/frontendBuild/allRoutesBuilder.ts +27 -8
- package/frontendBuild/precompressArtifacts.ts +35 -7
- package/frontendBuild/routeClientBuilder.ts +14 -2
- package/lint/no-bang-comment-in-client.grit +20 -0
- package/package.json +2 -2
- package/qualityScanner.test.ts +14 -0
- package/qualityScanner.ts +3 -25
- package/scanInfo.ts +2 -43
- package/workspaceLayout.test.ts +26 -0
- package/workspaceLayout.ts +61 -0
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
|
-
|
|
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
|
|
53
|
-
|
|
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 #
|
|
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
|
-
|
|
85
|
-
|
|
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] ${
|
|
111
|
+
this.#app.verbose(`[build-all] bundled ${delta.newEntries.length} entries (${Date.now() - started}ms)`);
|
|
92
112
|
return delta;
|
|
93
113
|
}
|
|
94
114
|
|
|
95
|
-
#
|
|
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
|
-
|
|
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
|
+
}
|
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.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.
|
|
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",
|
package/qualityScanner.test.ts
CHANGED
|
@@ -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 && !
|
|
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 && !
|
|
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
|
-
|
|
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);
|