@akanjs/devkit 2.4.2-rc.2 → 3.0.0-alpha.0

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,233 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import ts from "typescript";
3
+
4
+ /**
5
+ * A single Akan UI recipe discovered by scanning source. `variants` maps each variant key to its allowed option
6
+ * names (e.g. `{ variant: ["primary", "ghost"], size: ["sm", "md"] }`); a base-only recipe has `variants: {}`.
7
+ * `importFrom` is the module a consumer imports the recipe from (e.g. `@apps/minimal/ui`).
8
+ */
9
+ export interface RecipeInfo {
10
+ name: string;
11
+ importFrom: string;
12
+ variants: Record<string, string[]>;
13
+ defaultVariants?: Record<string, string>;
14
+ doc?: string;
15
+ /** The recipe's `base` class string when it is a plain string literal — the SSOT fingerprint. */
16
+ base?: string;
17
+ }
18
+
19
+ export interface RecipeSource {
20
+ path: string;
21
+ content: string;
22
+ importFrom: string;
23
+ }
24
+
25
+ /**
26
+ * Collects every recipe source under a `ui` folder. Recipes live one-per-file in a `Recipe/` folder
27
+ * (`recipe/` for the framework), so this reads the whole folder; the flat `Recipe.ts` is still read for
28
+ * apps that have not moved yet. Every consumer of `scanRecipes` must go through here — three call sites
29
+ * (AGENTS.md recipe index, `recipeGate` lint, MCP module context) hardcoded the flat path before, and each
30
+ * one fails silently (empty list, no error) when the file is absent.
31
+ */
32
+ export const collectRecipeSources = async (
33
+ uiDirPath: string,
34
+ importFrom: string,
35
+ basename = "Recipe",
36
+ ): Promise<RecipeSource[]> => {
37
+ const read = async (filePath: string): Promise<RecipeSource | null> => {
38
+ const content = await Bun.file(filePath)
39
+ .text()
40
+ .catch(() => "");
41
+ return content ? { path: filePath, content, importFrom } : null;
42
+ };
43
+ const flat = await read(`${uiDirPath}/${basename}.ts`);
44
+ const dirEntries = await readdir(`${uiDirPath}/${basename}`).catch(() => [] as string[]);
45
+ const fromDir = await Promise.all(
46
+ dirEntries
47
+ .filter((entry) => entry.endsWith(".ts") && entry !== "index.ts" && !/\.(test|spec)\.ts$/.test(entry))
48
+ .sort()
49
+ .map((entry) => read(`${uiDirPath}/${basename}/${entry}`)),
50
+ );
51
+ return [flat, ...fromDir].filter((source): source is RecipeSource => !!source);
52
+ };
53
+
54
+ /**
55
+ * Statically finds every `export const <name> = recipe(tv({ ... }))` across the given sources and extracts its
56
+ * variant surface + leading JSDoc one-liner. Detection is by the `recipe(tv(...))` call shape (not by name suffix,
57
+ * since base-only recipes like `appScreen` omit the `Recipe` suffix). Being AST-based, it never matches recipe
58
+ * definitions that appear only inside string/template literals (e.g. code examples in docs pages).
59
+ */
60
+ export const scanRecipes = (sources: RecipeSource[]): RecipeInfo[] => {
61
+ const recipes: RecipeInfo[] = [];
62
+ for (const source of sources) {
63
+ const sourceFile = ts.createSourceFile(
64
+ source.path,
65
+ source.content,
66
+ ts.ScriptTarget.Latest,
67
+ true,
68
+ ts.ScriptKind.TSX,
69
+ );
70
+ for (const statement of sourceFile.statements) {
71
+ if (!ts.isVariableStatement(statement) || !isExported(statement)) continue;
72
+ for (const declaration of statement.declarationList.declarations) {
73
+ if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue;
74
+ const parsed = parseRecipeCall(declaration.initializer);
75
+ if (!parsed) continue;
76
+ recipes.push({
77
+ name: declaration.name.text,
78
+ importFrom: source.importFrom,
79
+ variants: parsed.variants,
80
+ ...(parsed.defaultVariants ? { defaultVariants: parsed.defaultVariants } : {}),
81
+ ...(getLeadingDoc(source.content, statement) ? { doc: getLeadingDoc(source.content, statement) } : {}),
82
+ ...(parsed.base ? { base: parsed.base } : {}),
83
+ });
84
+ }
85
+ }
86
+ }
87
+ return recipes;
88
+ };
89
+
90
+ const isExported = (statement: ts.VariableStatement): boolean =>
91
+ statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false;
92
+
93
+ /** Matches `recipe( tv( <ObjectLiteral> ) )` and returns the variant surface, or null for anything else. */
94
+ const parseRecipeCall = (
95
+ initializer: ts.Expression,
96
+ ): { variants: Record<string, string[]>; defaultVariants?: Record<string, string>; base?: string } | null => {
97
+ if (!ts.isCallExpression(initializer)) return null;
98
+ if (!ts.isIdentifier(initializer.expression) || initializer.expression.text !== "recipe") return null;
99
+ const tvCall = initializer.arguments[0];
100
+ if (!tvCall || !ts.isCallExpression(tvCall)) return null;
101
+ if (!ts.isIdentifier(tvCall.expression) || tvCall.expression.text !== "tv") return null;
102
+ const config = tvCall.arguments[0];
103
+ if (!config || !ts.isObjectLiteralExpression(config)) return null;
104
+ return extractVariants(config);
105
+ };
106
+
107
+ const extractVariants = (config: ts.ObjectLiteralExpression) => {
108
+ const variants: Record<string, string[]> = {};
109
+ let defaultVariants: Record<string, string> | undefined;
110
+ let base: string | undefined;
111
+ for (const property of config.properties) {
112
+ if (!ts.isPropertyAssignment(property) || !isNamed(property.name)) continue;
113
+ const key = propName(property.name);
114
+ if (key === "base" && ts.isStringLiteral(property.initializer)) {
115
+ base = property.initializer.text;
116
+ } else if (key === "variants" && ts.isObjectLiteralExpression(property.initializer)) {
117
+ for (const variant of property.initializer.properties) {
118
+ if (!ts.isPropertyAssignment(variant) || !isNamed(variant.name)) continue;
119
+ if (!ts.isObjectLiteralExpression(variant.initializer)) continue;
120
+ variants[propName(variant.name)] = variant.initializer.properties
121
+ .filter((option): option is ts.PropertyAssignment => ts.isPropertyAssignment(option) && isNamed(option.name))
122
+ .map((option) => propName(option.name));
123
+ }
124
+ } else if (key === "defaultVariants" && ts.isObjectLiteralExpression(property.initializer)) {
125
+ defaultVariants = {};
126
+ for (const preset of property.initializer.properties) {
127
+ if (!ts.isPropertyAssignment(preset) || !isNamed(preset.name)) continue;
128
+ defaultVariants[propName(preset.name)] = ts.isStringLiteral(preset.initializer)
129
+ ? preset.initializer.text
130
+ : preset.initializer.getText();
131
+ }
132
+ }
133
+ }
134
+ return { variants, defaultVariants, base };
135
+ };
136
+
137
+ const isNamed = (name: ts.PropertyName): name is ts.Identifier | ts.StringLiteral =>
138
+ ts.isIdentifier(name) || ts.isStringLiteral(name);
139
+ const propName = (name: ts.Identifier | ts.StringLiteral): string => name.text;
140
+
141
+ export interface RecipeDuplicate {
142
+ recipe: string;
143
+ path: string;
144
+ line: number;
145
+ className: string;
146
+ }
147
+
148
+ /**
149
+ * Fraction of a recipe's base tokens an inline className must reproduce to count as a duplicate.
150
+ *
151
+ * Requiring *every* token (the original rule) only caught a verbatim copy of the whole base, which is the one
152
+ * form of duplication that essentially never happens: someone re-authoring a look reproduces the gist, not all
153
+ * eight tokens. So the check passed on exactly the near-duplicates it existed to find, and silently — it is an
154
+ * advisory, so nothing went red. A ratio catches those; false positives are cheap here for the same reason.
155
+ */
156
+ const DUPLICATE_TOKEN_RATIO = 0.7;
157
+
158
+ /** Minimum base tokens for a recipe to be worth fingerprinting at all. */
159
+ const MIN_FINGERPRINT_TOKENS = 3;
160
+
161
+ /**
162
+ * SSOT advisory: finds JSX `className` string values that hand-rewrite a recipe's base fingerprint instead of
163
+ * consuming the recipe. Only recipes whose base has 3+ distinctive tokens are checked — shorter fingerprints
164
+ * (`grid gap-3` …) are generic utilities and would flood the report with false positives. A className counts as
165
+ * a duplicate once it reproduces {@link DUPLICATE_TOKEN_RATIO} of those tokens, so a near-copy that drops or
166
+ * swaps one still reports. AST-scoped to real `className` attributes, so class strings inside doc-example
167
+ * template literals never match.
168
+ */
169
+ export const findInlineRecipeDuplicates = (
170
+ recipes: RecipeInfo[],
171
+ files: { path: string; content: string }[],
172
+ ): RecipeDuplicate[] => {
173
+ const fingerprints = recipes
174
+ .map((recipe) => ({ recipe: recipe.name, tokens: (recipe.base ?? "").split(/\s+/).filter(Boolean) }))
175
+ .filter((fingerprint) => fingerprint.tokens.length >= MIN_FINGERPRINT_TOKENS)
176
+ // Ceil so the threshold never rounds below the minimum: a 3-token base still needs 3 of 3.
177
+ .map((fingerprint) => ({ ...fingerprint, needed: Math.ceil(fingerprint.tokens.length * DUPLICATE_TOKEN_RATIO) }));
178
+ if (fingerprints.length === 0) return [];
179
+ const duplicates: RecipeDuplicate[] = [];
180
+ for (const file of files) {
181
+ const sourceFile = ts.createSourceFile(file.path, file.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
182
+ const visit = (node: ts.Node) => {
183
+ if (ts.isJsxAttribute(node) && node.name.getText(sourceFile) === "className" && node.initializer) {
184
+ for (const value of stringValuesIn(node.initializer)) {
185
+ const classSet = new Set(value.split(/\s+/));
186
+ for (const fingerprint of fingerprints) {
187
+ const matched = fingerprint.tokens.filter((token) => classSet.has(token)).length;
188
+ if (matched >= fingerprint.needed) {
189
+ const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
190
+ duplicates.push({ recipe: fingerprint.recipe, path: file.path, line: line + 1, className: value });
191
+ }
192
+ }
193
+ }
194
+ }
195
+ node.forEachChild(visit);
196
+ };
197
+ visit(sourceFile);
198
+ }
199
+ return duplicates;
200
+ };
201
+
202
+ const stringValuesIn = (node: ts.Node): string[] => {
203
+ const values: string[] = [];
204
+ const visit = (child: ts.Node) => {
205
+ if (
206
+ ts.isStringLiteral(child) ||
207
+ ts.isNoSubstitutionTemplateLiteral(child) ||
208
+ ts.isTemplateHead(child) ||
209
+ ts.isTemplateMiddle(child) ||
210
+ ts.isTemplateTail(child)
211
+ )
212
+ values.push(child.text);
213
+ child.forEachChild(visit);
214
+ };
215
+ visit(node);
216
+ return values;
217
+ };
218
+
219
+ /** The first non-empty line of the JSDoc/line comment immediately preceding the statement, markers stripped. */
220
+ const getLeadingDoc = (fullText: string, node: ts.Node): string | undefined => {
221
+ const ranges = ts.getLeadingCommentRanges(fullText, node.getFullStart());
222
+ if (!ranges?.length) return undefined;
223
+ const { pos, end } = ranges[ranges.length - 1];
224
+ const line = fullText
225
+ .slice(pos, end)
226
+ .replace(/^\/\*\*?/, "")
227
+ .replace(/\*\/\s*$/, "")
228
+ .replace(/^\/\/+/gm, "")
229
+ .split("\n")
230
+ .map((row) => row.replace(/^\s*\*\s?/, "").trim())
231
+ .find((row) => row.length > 0);
232
+ return line || undefined;
233
+ };
package/scanInfo.ts CHANGED
@@ -44,6 +44,9 @@ type DatabaseFileType = (typeof databaseFileTypes)[number];
44
44
  type ModuleKind = "database" | "service" | "scalar";
45
45
 
46
46
  const appRootAllowedFiles = new Set([
47
+ // 스코프 에이전트 가이드 — scan(write) 이 유지하는 색인 + 마커 밖 hand-written 내용 (agentsIndex.ts)
48
+ "AGENTS.md",
49
+ "CLAUDE.md",
47
50
  "akan.app.json",
48
51
  "akan.config.ts",
49
52
  "capacitor.config.ts",
@@ -11,7 +11,7 @@ import type { App } from "../commandDecorators";
11
11
  * - `packages: "external"` externalizes every bare specifier, including
12
12
  * `@apps/*` / `@libs/*` — workspace packages that must stay bundled
13
13
  * so the `"use client"` plugin can rewrite their exports — and ordinary
14
- * npm dependencies like `dayjs` / `clsx`, which the production runtime
14
+ * npm dependencies like `dayjs` / `tailwind-merge`, which the production runtime
15
15
  * package.json does not install for the SSR pages artifact.
16
16
  * - Top-level `external: [...]` applies to macro-time module resolution
17
17
  * too; any `with { type: "macro" }` import chain that transitively
@@ -169,7 +169,7 @@ export async function createExternalizeFrameworkPlugin(options: ExternalizeFrame
169
169
  // that case directly.
170
170
  return undefined;
171
171
  }
172
- // Everything else (ordinary npm dependencies like dayjs / clsx /
172
+ // Everything else (ordinary npm dependencies like dayjs / tailwind-merge /
173
173
  // immer) is bundled into the pages artifact.
174
174
  return undefined;
175
175
  });