@akanjs/devkit 3.0.0-alpha.1 → 3.0.0-alpha.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agentsIndex.ts +26 -0
- package/akanContext.ts +43 -31
- package/applicationBuildRunner.ts +1 -1
- package/biome.base.json +313 -0
- package/biomeBase.ts +9 -0
- package/capacitorApp.test.ts +8 -0
- package/capacitorApp.ts +112 -18
- package/executors.test.ts +3 -1
- package/executors.ts +2 -1
- package/frontendBuild/allRoutesBuilder.ts +27 -8
- package/frontendBuild/cssCandidateCache.test.ts +38 -0
- package/frontendBuild/cssCandidateCache.ts +15 -3
- package/frontendBuild/cssCompiler.ts +60 -2
- package/frontendBuild/cssImportResolver.ts +8 -7
- package/frontendBuild/frontendBuild.test.ts +54 -0
- package/frontendBuild/precompressArtifacts.ts +35 -7
- package/frontendBuild/routeClientBuilder.ts +14 -2
- package/lint/no-bang-comment-in-client.grit +20 -0
- package/lint/no-import-client-in-server.grit +48 -0
- package/lint/no-import-server-in-client.grit +45 -0
- package/lint/no-return-in-store-action.grit +35 -0
- package/linter.ts +17 -12
- package/mcpScanner.ts +217 -0
- package/package.json +3 -2
- package/qualityScanner.test.ts +192 -0
- package/qualityScanner.ts +16 -45
- package/repoIdentity.ts +42 -0
- package/routeSourceValidator.test.ts +41 -0
- package/routeSourceValidator.ts +2 -44
- package/scanInfo.ts +2 -43
- package/storeScanner.ts +173 -0
- package/workspaceLayout.test.ts +26 -0
- package/workspaceLayout.ts +60 -0
package/qualityScanner.test.ts
CHANGED
|
@@ -183,3 +183,195 @@ describe("AkanQualityScanner ssr rules", () => {
|
|
|
183
183
|
expect(ssrBalance[2]).toMatchObject({ scope: "workspace", serverMass: 3, clientMass: 1 });
|
|
184
184
|
});
|
|
185
185
|
});
|
|
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
|
+
|
|
365
|
+
describe("AkanQualityScanner layout rules", () => {
|
|
366
|
+
test("flags an unknown app root file but not a facet entrypoint", async () => {
|
|
367
|
+
const root = await makeWorkspace({
|
|
368
|
+
"apps/demo/client.ts": "export const client = 1;\n",
|
|
369
|
+
"apps/demo/helper.ts": "export const helper = 1;\n",
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.layout.app-root-file");
|
|
373
|
+
|
|
374
|
+
expect(warnings).toHaveLength(1);
|
|
375
|
+
expect(warnings[0]?.file).toBe("apps/demo/helper.ts");
|
|
376
|
+
});
|
|
377
|
+
});
|
package/qualityScanner.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { RESERVED_ROUTE_CONFIG_EXPORTS } from "akanjs/common";
|
|
4
5
|
import ignore from "ignore";
|
|
5
6
|
import ts from "typescript";
|
|
6
7
|
import { AbstractDoc } from "./abstractDoc";
|
|
8
|
+
import { McpScanner } from "./mcpScanner";
|
|
7
9
|
import { formatSsrBalance, type SsrBalanceEntry, SsrScanner } from "./ssrScanner";
|
|
10
|
+
import { StoreScanner } from "./storeScanner";
|
|
11
|
+
import { appRootAllowedFiles, libFacetRootAllowedFiles } from "./workspaceLayout";
|
|
8
12
|
|
|
9
13
|
type QualitySeverity = "warning";
|
|
10
|
-
type QualityScope = "global" | "file" | "convention" | "layout" | "ssr";
|
|
14
|
+
type QualityScope = "global" | "file" | "convention" | "layout" | "ssr" | "mcp" | "agent";
|
|
11
15
|
|
|
12
16
|
export interface QualityWarning {
|
|
13
17
|
rule: string;
|
|
@@ -90,29 +94,6 @@ const SUGGESTED_RULES = [
|
|
|
90
94
|
"Avoid large mixed-purpose class files; class export files should import helpers from neighboring utility files instead of declaring them inline.",
|
|
91
95
|
];
|
|
92
96
|
|
|
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
97
|
const CONVENTION_SUFFIXES = [
|
|
117
98
|
".constant.ts",
|
|
118
99
|
".dictionary.ts",
|
|
@@ -122,24 +103,6 @@ const CONVENTION_SUFFIXES = [
|
|
|
122
103
|
".store.ts",
|
|
123
104
|
] as const;
|
|
124
105
|
|
|
125
|
-
// Non-PascalCase exports the framework recognizes on page/layout route modules (see PageModule/LayoutModule
|
|
126
|
-
// in pkgs/akanjs/client/csrTypes.ts). PascalCase route exports (Loading, NotFound, Error) pass the component
|
|
127
|
-
// check, and the `default` export is handled separately.
|
|
128
|
-
const PAGE_RESERVED_EXPORTS = new Set([
|
|
129
|
-
"pageConfig",
|
|
130
|
-
"head",
|
|
131
|
-
"metadata",
|
|
132
|
-
"generateHead",
|
|
133
|
-
"generateMetadata",
|
|
134
|
-
"fonts",
|
|
135
|
-
"manifest",
|
|
136
|
-
"theme",
|
|
137
|
-
"reconnect",
|
|
138
|
-
"wsConnect",
|
|
139
|
-
"layoutStyle",
|
|
140
|
-
"gaTrackingId",
|
|
141
|
-
]);
|
|
142
|
-
|
|
143
106
|
// How to remediate each rule, keyed by rule id. Surfaced as a `fix:` line per warning (text + JSON output)
|
|
144
107
|
// so the scan result tells the reader what to do, not just what is wrong.
|
|
145
108
|
const RULE_FIXES: Record<string, string> = {
|
|
@@ -183,6 +146,12 @@ const RULE_FIXES: Record<string, string> = {
|
|
|
183
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.",
|
|
184
147
|
"akan.ssr.template-client-state":
|
|
185
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.",
|
|
186
155
|
};
|
|
187
156
|
|
|
188
157
|
function getRuleFix(rule: string): string | undefined {
|
|
@@ -213,6 +182,8 @@ export class AkanQualityScanner {
|
|
|
213
182
|
...sourceFiles.flatMap((sourceFile) => this.#scanLayoutQuality(sourceFile)),
|
|
214
183
|
...abstractFiles.flatMap((abstractFile) => this.#scanAbstractQuality(abstractFile)),
|
|
215
184
|
...ssr.warnings,
|
|
185
|
+
...new McpScanner().scan(sourceFiles),
|
|
186
|
+
...new StoreScanner().scan(sourceFiles),
|
|
216
187
|
];
|
|
217
188
|
|
|
218
189
|
return {
|
|
@@ -449,7 +420,7 @@ export class AkanQualityScanner {
|
|
|
449
420
|
#scanLayoutQuality(sourceFile: SourceFileInfo): QualityWarning[] {
|
|
450
421
|
const segments = sourceFile.file.split("/");
|
|
451
422
|
const warnings: QualityWarning[] = [];
|
|
452
|
-
if (segments[0] === "apps" && segments.length === 3 && !
|
|
423
|
+
if (segments[0] === "apps" && segments.length === 3 && !appRootAllowedFiles.has(segments[2])) {
|
|
453
424
|
warnings.push({
|
|
454
425
|
rule: "akan.layout.app-root-file",
|
|
455
426
|
scope: "layout",
|
|
@@ -460,7 +431,7 @@ export class AkanQualityScanner {
|
|
|
460
431
|
}
|
|
461
432
|
|
|
462
433
|
const libRootFile = getLibRootFile(sourceFile.file);
|
|
463
|
-
if (libRootFile && !
|
|
434
|
+
if (libRootFile && !libFacetRootAllowedFiles.has(libRootFile)) {
|
|
464
435
|
warnings.push({
|
|
465
436
|
rule: "akan.layout.lib-root-file",
|
|
466
437
|
scope: "layout",
|
|
@@ -691,7 +662,7 @@ function isRestrictedInternalKind(kind: ComponentFileDeclaration["kind"]) {
|
|
|
691
662
|
|
|
692
663
|
function isAllowedComponentExport(declaration: ComponentFileDeclaration, isPage: boolean) {
|
|
693
664
|
if (isComponentValueKind(declaration.kind) && isPascalCaseName(declaration.name)) return true;
|
|
694
|
-
return isPage &&
|
|
665
|
+
return isPage && RESERVED_ROUTE_CONFIG_EXPORTS.has(declaration.name);
|
|
695
666
|
}
|
|
696
667
|
|
|
697
668
|
function isPascalCaseName(name: string) {
|
package/repoIdentity.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
// Resolved once per root: every CLI command builds a WorkspaceExecutor, and this would otherwise fork git on each.
|
|
5
|
+
const resolved = new Map<string, string>();
|
|
6
|
+
|
|
7
|
+
const readRemoteName = (workspaceRoot: string): string | null => {
|
|
8
|
+
try {
|
|
9
|
+
const url = execFileSync("git", ["config", "--get", "remote.origin.url"], {
|
|
10
|
+
cwd: workspaceRoot,
|
|
11
|
+
encoding: "utf-8",
|
|
12
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
13
|
+
}).trim();
|
|
14
|
+
// Both remote spellings end in the repository: git@host:owner/name.git and https://host/owner/name.git.
|
|
15
|
+
return (
|
|
16
|
+
url
|
|
17
|
+
.replace(/\.git$/, "")
|
|
18
|
+
.split(/[/:]/)
|
|
19
|
+
.pop() || null
|
|
20
|
+
);
|
|
21
|
+
} catch {
|
|
22
|
+
// No git, no origin, or no git binary — a fresh `akan workspace` before its first commit lands here.
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The repository's own name.
|
|
29
|
+
*
|
|
30
|
+
* Deriving it from the working directory made every generated file that names the repo — the AGENTS.md title, its
|
|
31
|
+
* `- Repo:` line — depend on what each person happened to call the folder they cloned into, so one commit rendered
|
|
32
|
+
* a different guide per developer and the diff never settled. The origin remote is the one identity every clone
|
|
33
|
+
* shares. `AKAN_PUBLIC_REPO_NAME` is deliberately not consulted: that is a deployment namespace (queue prefixes,
|
|
34
|
+
* cache keys, secret paths) which a monorepo hosting several products legitimately points somewhere else.
|
|
35
|
+
*/
|
|
36
|
+
export const resolveRepoName = (workspaceRoot: string): string => {
|
|
37
|
+
const cached = resolved.get(workspaceRoot);
|
|
38
|
+
if (cached) return cached;
|
|
39
|
+
const repoName = readRemoteName(workspaceRoot) ?? path.basename(workspaceRoot);
|
|
40
|
+
resolved.set(workspaceRoot, repoName);
|
|
41
|
+
return repoName;
|
|
42
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { RouteSourceValidator } from "./routeSourceValidator";
|
|
4
|
+
|
|
5
|
+
const validate = (source: string, kind: "page" | "layout", rootLayout = false) =>
|
|
6
|
+
RouteSourceValidator.validateRouteSourceExports(source, `page/_${kind}.tsx`, kind, { rootLayout });
|
|
7
|
+
|
|
8
|
+
describe("RouteSourceValidator", () => {
|
|
9
|
+
test("accepts every root-layout config export the route tree honors", () => {
|
|
10
|
+
const source = [
|
|
11
|
+
"export default function Layout() { return null; }",
|
|
12
|
+
"export const fonts = [];",
|
|
13
|
+
"export const manifest = {};",
|
|
14
|
+
'export const theme = "dark";',
|
|
15
|
+
"export const reconnect = true;",
|
|
16
|
+
"export const wsConnect = true;",
|
|
17
|
+
"export const layoutStyle = {};",
|
|
18
|
+
'export const gaTrackingId = "G-1";',
|
|
19
|
+
].join("\n");
|
|
20
|
+
|
|
21
|
+
expect(() => validate(source, "layout", true)).not.toThrow();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("rejects root-layout-only exports on a nested layout and on a page", () => {
|
|
25
|
+
const source = ["export default function Layout() { return null; }", "export const wsConnect = true;"].join("\n");
|
|
26
|
+
|
|
27
|
+
expect(() => validate(source, "layout")).toThrow('unsupported export "wsConnect"');
|
|
28
|
+
expect(() => validate(source, "page")).toThrow('unsupported export "wsConnect"');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("reads devOnly off pageConfig without evaluating the module", () => {
|
|
32
|
+
const source = [
|
|
33
|
+
"export default function Page() { return null; }",
|
|
34
|
+
"export const pageConfig = { devOnly: true };",
|
|
35
|
+
].join("\n");
|
|
36
|
+
|
|
37
|
+
expect(RouteSourceValidator.validateRouteSourceExports(source, "page/_index.tsx", "page")).toEqual({
|
|
38
|
+
devOnly: true,
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
});
|
package/routeSourceValidator.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getRouteExports } from "akanjs/common";
|
|
1
2
|
import ts from "typescript";
|
|
2
3
|
|
|
3
4
|
/** What the build needs out of a route module without evaluating it. */
|
|
@@ -16,44 +17,6 @@ export interface RouteSourceInfo {
|
|
|
16
17
|
* validate a route source stay lean.
|
|
17
18
|
*/
|
|
18
19
|
export class RouteSourceValidator {
|
|
19
|
-
static readonly #pageExports = new Set([
|
|
20
|
-
"default",
|
|
21
|
-
"pageConfig",
|
|
22
|
-
"head",
|
|
23
|
-
"metadata",
|
|
24
|
-
"generateHead",
|
|
25
|
-
"generateMetadata",
|
|
26
|
-
"Loading",
|
|
27
|
-
]);
|
|
28
|
-
static readonly #rootLayoutExports = new Set([
|
|
29
|
-
"default",
|
|
30
|
-
"pageConfig",
|
|
31
|
-
"head",
|
|
32
|
-
"metadata",
|
|
33
|
-
"generateHead",
|
|
34
|
-
"generateMetadata",
|
|
35
|
-
"fonts",
|
|
36
|
-
"manifest",
|
|
37
|
-
"theme",
|
|
38
|
-
"reconnect",
|
|
39
|
-
"layoutStyle",
|
|
40
|
-
"gaTrackingId",
|
|
41
|
-
"Loading",
|
|
42
|
-
"NotFound",
|
|
43
|
-
"Error",
|
|
44
|
-
]);
|
|
45
|
-
static readonly #layoutExports = new Set([
|
|
46
|
-
"default",
|
|
47
|
-
"pageConfig",
|
|
48
|
-
"head",
|
|
49
|
-
"metadata",
|
|
50
|
-
"generateHead",
|
|
51
|
-
"generateMetadata",
|
|
52
|
-
"Loading",
|
|
53
|
-
"NotFound",
|
|
54
|
-
"Error",
|
|
55
|
-
]);
|
|
56
|
-
|
|
57
20
|
static validateRouteSourceExports(
|
|
58
21
|
source: string,
|
|
59
22
|
filePath: string,
|
|
@@ -61,12 +24,7 @@ export class RouteSourceValidator {
|
|
|
61
24
|
options: { rootLayout?: boolean } = {},
|
|
62
25
|
): RouteSourceInfo {
|
|
63
26
|
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
64
|
-
const allowed =
|
|
65
|
-
kind === "page"
|
|
66
|
-
? RouteSourceValidator.#pageExports
|
|
67
|
-
: options.rootLayout
|
|
68
|
-
? RouteSourceValidator.#rootLayoutExports
|
|
69
|
-
: RouteSourceValidator.#layoutExports;
|
|
27
|
+
const allowed = getRouteExports(kind, { rootLayout: options.rootLayout });
|
|
70
28
|
const exported = new Set<string>();
|
|
71
29
|
const assertExport = (name: string) => {
|
|
72
30
|
if (!allowed.has(name)) {
|
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) {
|