@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.
@@ -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
+ }
@@ -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,60 @@
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 의 목록도 같이 갱신한다.
8
+ */
9
+
10
+ export const appRootAllowedFiles = new Set([
11
+ // 스코프 에이전트 가이드 — scan(write) 이 유지하는 색인 + 마커 밖 hand-written 내용 (agentsIndex.ts)
12
+ "AGENTS.md",
13
+ "CLAUDE.md",
14
+ "akan.app.json",
15
+ "akan.config.ts",
16
+ "capacitor.config.ts",
17
+ "client.ts",
18
+ "main.ts",
19
+ "package.json",
20
+ "server.ts",
21
+ "tsconfig.json",
22
+ "tsconfig.tsbuildinfo",
23
+ ]);
24
+
25
+ export const appRootAllowedDirs = new Set([
26
+ ".akan",
27
+ "android",
28
+ "common",
29
+ "env",
30
+ "ios",
31
+ "lib",
32
+ "mobile",
33
+ "page",
34
+ "plugin",
35
+ "private",
36
+ "public",
37
+ "script",
38
+ "secrets",
39
+ "srvkit",
40
+ "ui",
41
+ "webkit",
42
+ ]);
43
+
44
+ export const libFacetRootAllowedFiles = new Set([
45
+ "cnst.ts",
46
+ "db.ts",
47
+ "dict.ts",
48
+ "option.ts",
49
+ "sig.ts",
50
+ "srv.ts",
51
+ "st.ts",
52
+ "useClient.ts",
53
+ "useServer.ts",
54
+ ]);
55
+
56
+ /**
57
+ * scanSync 는 앱 루트를 `Bun.Glob("*")` 로 읽어 dotfile 을 아예 보지 못한다. 디렉터리를 직접 읽는
58
+ * doctor 가 그 차이만큼 `.DS_Store` 같은 툴 산출물을 에러로 올리므로 같은 기준으로 걸러낸다.
59
+ */
60
+ export const isScannedAppRootEntry = (name: string) => !name.startsWith(".") || appRootAllowedDirs.has(name);