@akanjs/devkit 2.4.2-rc.3 → 3.0.0-alpha.1
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.test.ts +84 -0
- package/agentsIndex.ts +146 -0
- package/akanContext.ts +122 -0
- package/executors.ts +26 -0
- package/frontendBuild/frontendBuild.test.ts +3 -1
- package/frontendBuild/index.ts +3 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +2 -0
- package/frontendBuild/styleContract.ts +29 -0
- package/frontendBuild/styleGuard.test.ts +165 -0
- package/frontendBuild/styleGuard.ts +322 -0
- package/frontendBuild/themeValidator.test.ts +70 -0
- package/frontendBuild/themeValidator.ts +150 -0
- package/index.ts +1 -0
- package/integration/devResourceProbe.ts +7 -2
- package/integration/ssrMemoryProbe.ts +542 -0
- package/lint/no-arbitrary-color.grit +19 -0
- package/lint/no-daisyui-legacy-class.grit +20 -0
- package/lint/no-inline-color.grit +19 -0
- package/lint/no-interpolated-arbitrary-class.grit +24 -0
- package/lint/no-raw-palette-class.grit +25 -0
- package/package.json +2 -3
- package/qualityScanner.test.ts +140 -1
- package/qualityScanner.ts +41 -2
- package/recipeScanner.test.ts +183 -0
- package/recipeScanner.ts +233 -0
- package/scanInfo.ts +3 -0
- package/spinner.test.ts +81 -0
- package/spinner.ts +22 -2
- package/ssrScanner.ts +409 -0
- package/transforms/externalizeFrameworkPlugin.ts +2 -2
package/qualityScanner.test.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { AbstractDoc } from "./abstractDoc";
|
|
6
|
-
import { AkanQualityScanner } from "./qualityScanner";
|
|
6
|
+
import { AkanQualityScanner, type QualityScanResult } from "./qualityScanner";
|
|
7
7
|
|
|
8
8
|
const tempRoots: string[] = [];
|
|
9
9
|
|
|
@@ -44,3 +44,142 @@ describe("AkanQualityScanner abstract rule", () => {
|
|
|
44
44
|
expect(warnings[0]?.fix).toContain("akan compact");
|
|
45
45
|
});
|
|
46
46
|
});
|
|
47
|
+
|
|
48
|
+
const staticMarkup = (elementNum: number) =>
|
|
49
|
+
Array.from({ length: elementNum }, (_, idx) => ` <p className="text-sm">row ${idx}</p>`).join("\n");
|
|
50
|
+
|
|
51
|
+
const rulesOf = (result: QualityScanResult, rule: string) => result.warnings.filter((warning) => warning.rule === rule);
|
|
52
|
+
|
|
53
|
+
describe("AkanQualityScanner ssr rules", () => {
|
|
54
|
+
test("flags a client file that uses no client-only capability", async () => {
|
|
55
|
+
const root = await makeWorkspace({
|
|
56
|
+
"apps/demo/ui/Plain.tsx": `"use client";\nexport const Plain = () => <div>plain</div>;\n`,
|
|
57
|
+
"apps/demo/ui/Interactive.tsx": `"use client";\nexport const Interactive = () => <button onClick={() => null}>go</button>;\n`,
|
|
58
|
+
"apps/demo/ui/Hooked.tsx": `"use client";\nimport { useState } from "react";\nexport const Hooked = () => {\n const [open] = useState(false);\n return <div>{open ? "y" : "n"}</div>;\n};\n`,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.ssr.unnecessary-use-client");
|
|
62
|
+
|
|
63
|
+
expect(warnings).toHaveLength(1);
|
|
64
|
+
expect(warnings[0]?.file).toBe("apps/demo/ui/Plain.tsx");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("keeps the directive on a third-party wrapper and on an index_ boundary", async () => {
|
|
68
|
+
const root = await makeWorkspace({
|
|
69
|
+
"apps/demo/ui/Chart.tsx": `"use client";\nimport { Bar } from "react-chartjs-2";\nexport const Chart = () => <Bar data={{}} />;\n`,
|
|
70
|
+
"apps/demo/ui/Lazy/index_.tsx": `"use client";\nexport { Inner } from "./Inner";\n`,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(rulesOf(await new AkanQualityScanner().scan(root), "akan.ssr.unnecessary-use-client")).toHaveLength(0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("flags a static component and a mostly-static component inside a client file", async () => {
|
|
77
|
+
const root = await makeWorkspace({
|
|
78
|
+
"apps/demo/ui/Panels.tsx": [
|
|
79
|
+
`"use client";`,
|
|
80
|
+
`import { useState } from "react";`,
|
|
81
|
+
`export const StaticPanel = () => (`,
|
|
82
|
+
` <section>`,
|
|
83
|
+
staticMarkup(5),
|
|
84
|
+
` </section>`,
|
|
85
|
+
`);`,
|
|
86
|
+
`export const MixedPanel = () => {`,
|
|
87
|
+
` const [open, setOpen] = useState(false);`,
|
|
88
|
+
` return (`,
|
|
89
|
+
` <section>`,
|
|
90
|
+
staticMarkup(12),
|
|
91
|
+
` <span>{open ? "open" : "shut"}</span>`,
|
|
92
|
+
` </section>`,
|
|
93
|
+
` );`,
|
|
94
|
+
`};`,
|
|
95
|
+
"",
|
|
96
|
+
].join("\n"),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const result = await new AkanQualityScanner().scan(root);
|
|
100
|
+
const staticWarnings = rulesOf(result, "akan.ssr.client-static-component");
|
|
101
|
+
const mixedWarnings = rulesOf(result, "akan.ssr.client-static-markup");
|
|
102
|
+
|
|
103
|
+
expect(staticWarnings).toHaveLength(1);
|
|
104
|
+
expect(staticWarnings[0]?.message).toContain("StaticPanel");
|
|
105
|
+
expect(staticWarnings[0]?.fix).toContain("server file");
|
|
106
|
+
expect(mixedWarnings).toHaveLength(1);
|
|
107
|
+
expect(mixedWarnings[0]?.message).toContain("MixedPanel");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("flags a mount-only load but not a reactive one", async () => {
|
|
111
|
+
const root = await makeWorkspace({
|
|
112
|
+
"apps/demo/lib/post/Post.Zone.tsx": [
|
|
113
|
+
`"use client";`,
|
|
114
|
+
`import { useEffect } from "react";`,
|
|
115
|
+
`export const List = ({ tag }: { tag: string }) => {`,
|
|
116
|
+
` useEffect(() => {`,
|
|
117
|
+
` void st.do.initPostInPublic();`,
|
|
118
|
+
` }, []);`,
|
|
119
|
+
` useEffect(() => {`,
|
|
120
|
+
` void st.do.getPostListInTag(tag);`,
|
|
121
|
+
` }, [tag]);`,
|
|
122
|
+
` return <div>{tag}</div>;`,
|
|
123
|
+
`};`,
|
|
124
|
+
"",
|
|
125
|
+
].join("\n"),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.ssr.client-mount-load");
|
|
129
|
+
|
|
130
|
+
expect(warnings).toHaveLength(1);
|
|
131
|
+
expect(warnings[0]?.message).toContain("st.do.initPostInPublic");
|
|
132
|
+
expect(warnings[0]?.fix).toContain("init/view");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("flags useState in a Template", async () => {
|
|
136
|
+
const root = await makeWorkspace({
|
|
137
|
+
"apps/demo/lib/post/Post.Template.tsx": `"use client";\nimport { useState } from "react";\nexport const General = () => {\n const [draft, setDraft] = useState("");\n return <input value={draft} onChange={(e) => setDraft(e.target.value)} />;\n};\n`,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.ssr.template-client-state");
|
|
141
|
+
|
|
142
|
+
expect(warnings).toHaveLength(1);
|
|
143
|
+
expect(warnings[0]?.fix).toContain("st.do.setFieldOnX");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("flags a module that renders only from client files", async () => {
|
|
147
|
+
const root = await makeWorkspace({
|
|
148
|
+
"apps/demo/lib/post/Post.Zone.tsx": [
|
|
149
|
+
`"use client";`,
|
|
150
|
+
`import { useState } from "react";`,
|
|
151
|
+
`export const Card = () => {`,
|
|
152
|
+
` const [open] = useState(false);`,
|
|
153
|
+
` return (`,
|
|
154
|
+
` <section>`,
|
|
155
|
+
staticMarkup(14),
|
|
156
|
+
` <span>{open ? "open" : "shut"}</span>`,
|
|
157
|
+
` </section>`,
|
|
158
|
+
` );`,
|
|
159
|
+
`};`,
|
|
160
|
+
"",
|
|
161
|
+
].join("\n"),
|
|
162
|
+
"libs/shared/lib/user/User.Zone.tsx": `"use client";\nimport { st } from "@libs/shared/client";\nexport const Self = () => <User.View.General user={st.use.self()} />;\n`,
|
|
163
|
+
"libs/shared/lib/user/User.View.tsx": `export const General = ({ name }: { name: string }) => <div>{name}</div>;\n`,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.ssr.module-missing-server-view");
|
|
167
|
+
|
|
168
|
+
expect(warnings).toHaveLength(1);
|
|
169
|
+
expect(warnings[0]?.message).toContain("apps/demo/lib/post");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("measures the server render share per scope and for the workspace", async () => {
|
|
173
|
+
const root = await makeWorkspace({
|
|
174
|
+
"apps/demo/ui/Server.tsx": `export const Server = () => (\n <section>\n <p>a</p>\n <p>b</p>\n </section>\n);\n`,
|
|
175
|
+
"libs/shared/ui/Client.tsx": `"use client";\nexport const Client = () => <button onClick={() => null}>go</button>;\n`,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const { ssrBalance } = await new AkanQualityScanner().scan(root);
|
|
179
|
+
|
|
180
|
+
expect(ssrBalance.map((entry) => entry.scope)).toEqual(["apps/demo", "libs/shared", "workspace"]);
|
|
181
|
+
expect(ssrBalance[0]).toMatchObject({ serverMass: 3, clientMass: 0, serverShare: 1 });
|
|
182
|
+
expect(ssrBalance[1]).toMatchObject({ serverMass: 0, clientMass: 1 });
|
|
183
|
+
expect(ssrBalance[2]).toMatchObject({ scope: "workspace", serverMass: 3, clientMass: 1 });
|
|
184
|
+
});
|
|
185
|
+
});
|
package/qualityScanner.ts
CHANGED
|
@@ -4,9 +4,10 @@ import path from "node:path";
|
|
|
4
4
|
import ignore from "ignore";
|
|
5
5
|
import ts from "typescript";
|
|
6
6
|
import { AbstractDoc } from "./abstractDoc";
|
|
7
|
+
import { formatSsrBalance, type SsrBalanceEntry, SsrScanner } from "./ssrScanner";
|
|
7
8
|
|
|
8
9
|
type QualitySeverity = "warning";
|
|
9
|
-
type QualityScope = "global" | "file" | "convention" | "layout";
|
|
10
|
+
type QualityScope = "global" | "file" | "convention" | "layout" | "ssr";
|
|
10
11
|
|
|
11
12
|
export interface QualityWarning {
|
|
12
13
|
rule: string;
|
|
@@ -23,10 +24,11 @@ export interface QualityScanResult {
|
|
|
23
24
|
workspaceRoot: string;
|
|
24
25
|
scannedFiles: number;
|
|
25
26
|
warnings: QualityWarning[];
|
|
27
|
+
ssrBalance: SsrBalanceEntry[];
|
|
26
28
|
suggestedRules: string[];
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
interface SourceFileInfo {
|
|
31
|
+
export interface SourceFileInfo {
|
|
30
32
|
file: string;
|
|
31
33
|
absolutePath: string;
|
|
32
34
|
content: string;
|
|
@@ -169,6 +171,18 @@ const RULE_FIXES: Record<string, string> = {
|
|
|
169
171
|
"Move the file into a domain module folder under lib/; keep lib root limited to generated support facets.",
|
|
170
172
|
"akan.layout.module-ui-file":
|
|
171
173
|
"Rename the file to an allowed module UI name, or move it to ui/ if it is not a module component.",
|
|
174
|
+
"akan.ssr.unnecessary-use-client":
|
|
175
|
+
'Delete the "use client" directive so the file renders on the server. If it exists only to wrap one client child, drop the wrapper and use the child directly.',
|
|
176
|
+
"akan.ssr.client-static-component":
|
|
177
|
+
"Move the component to a server file — a <Model>.Unit.tsx / <Model>.View.tsx for a module, or a ui/ file with no directive — and reference it from the client file.",
|
|
178
|
+
"akan.ssr.client-static-markup":
|
|
179
|
+
"Keep the interactive element in the client component and hoist the static subtree into a server component, then accept it as `children` or render it through a Unit/View reference.",
|
|
180
|
+
"akan.ssr.client-mount-load":
|
|
181
|
+
"Load the data in the route with `fetch.initX(...)` / `fetch.viewX(...)` and pass the init/view object down as a prop; the client store hydrates from it and the effect goes away.",
|
|
182
|
+
"akan.ssr.module-missing-server-view":
|
|
183
|
+
"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
|
+
"akan.ssr.template-client-state":
|
|
185
|
+
"Bind the field to the store instead: `value={xForm.field}` with `onChange={st.do.setFieldOnX}`.",
|
|
172
186
|
};
|
|
173
187
|
|
|
174
188
|
function getRuleFix(rule: string): string | undefined {
|
|
@@ -190,6 +204,7 @@ export class AkanQualityScanner {
|
|
|
190
204
|
.filter((file) => AbstractDoc.isAbstractPath(file))
|
|
191
205
|
.map((file) => this.#readTextFile(workspaceRoot, file)),
|
|
192
206
|
);
|
|
207
|
+
const ssr = new SsrScanner().scan(sourceFiles);
|
|
193
208
|
const warnings = [
|
|
194
209
|
...this.#scanGlobalQuality(sourceFiles),
|
|
195
210
|
...sourceFiles.flatMap((sourceFile) => this.#scanSingleFileQuality(sourceFile)),
|
|
@@ -197,6 +212,7 @@ export class AkanQualityScanner {
|
|
|
197
212
|
...sourceFiles.flatMap((sourceFile) => this.#scanConventionQuality(sourceFile)),
|
|
198
213
|
...sourceFiles.flatMap((sourceFile) => this.#scanLayoutQuality(sourceFile)),
|
|
199
214
|
...abstractFiles.flatMap((abstractFile) => this.#scanAbstractQuality(abstractFile)),
|
|
215
|
+
...ssr.warnings,
|
|
200
216
|
];
|
|
201
217
|
|
|
202
218
|
return {
|
|
@@ -205,6 +221,7 @@ export class AkanQualityScanner {
|
|
|
205
221
|
warnings: warnings
|
|
206
222
|
.map((warning) => ({ ...warning, fix: warning.fix ?? getRuleFix(warning.rule) }))
|
|
207
223
|
.sort(compareWarnings),
|
|
224
|
+
ssrBalance: ssr.balance,
|
|
208
225
|
suggestedRules: SUGGESTED_RULES,
|
|
209
226
|
};
|
|
210
227
|
}
|
|
@@ -470,6 +487,10 @@ export function formatQualityScanResult(result: QualityScanResult) {
|
|
|
470
487
|
"",
|
|
471
488
|
...formatQualityWarnings(result.warnings),
|
|
472
489
|
"",
|
|
490
|
+
"SSR balance (component files, JSX elements rendered per side):",
|
|
491
|
+
"",
|
|
492
|
+
...formatSsrBalance(result.ssrBalance),
|
|
493
|
+
"",
|
|
473
494
|
"Suggested quality rules:",
|
|
474
495
|
"",
|
|
475
496
|
...result.suggestedRules.map((rule) => ` - ${rule}`),
|
|
@@ -477,6 +498,24 @@ export function formatQualityScanResult(result: QualityScanResult) {
|
|
|
477
498
|
return sections.join("\n");
|
|
478
499
|
}
|
|
479
500
|
|
|
501
|
+
export function formatSsrScanResult(result: QualityScanResult) {
|
|
502
|
+
const sections = [
|
|
503
|
+
"Akan SSR Balance Scan",
|
|
504
|
+
`workspace: ${result.workspaceRoot}`,
|
|
505
|
+
`scanned files: ${result.scannedFiles}`,
|
|
506
|
+
`ssr warnings: ${result.warnings.length}`,
|
|
507
|
+
"",
|
|
508
|
+
"Server render share (component files, JSX elements rendered per side):",
|
|
509
|
+
"",
|
|
510
|
+
...formatSsrBalance(result.ssrBalance),
|
|
511
|
+
"",
|
|
512
|
+
"Warnings:",
|
|
513
|
+
"",
|
|
514
|
+
...formatQualityWarnings(result.warnings),
|
|
515
|
+
];
|
|
516
|
+
return sections.join("\n");
|
|
517
|
+
}
|
|
518
|
+
|
|
480
519
|
export function formatQualityWarnings(warnings: QualityWarning[]) {
|
|
481
520
|
if (warnings.length === 0) return ["No warnings found."];
|
|
482
521
|
return warnings.flatMap((warning) => {
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { collectRecipeSources, findInlineRecipeDuplicates, type RecipeInfo, scanRecipes } from "./recipeScanner";
|
|
6
|
+
|
|
7
|
+
const byName = (recipes: RecipeInfo[], name: string) => recipes.find((recipe) => recipe.name === name);
|
|
8
|
+
|
|
9
|
+
// Mirrors pkgs/akanjs/ui/recipe/ (framework) — two recipes in one source, variant + size surfaces.
|
|
10
|
+
// The scanner is per-source, so a folder of one-recipe files and a legacy multi-recipe file both parse.
|
|
11
|
+
const FRAMEWORK = `
|
|
12
|
+
import { recipe, tv } from "./recipeFactory";
|
|
13
|
+
export const buttonRecipe = recipe(
|
|
14
|
+
tv({
|
|
15
|
+
base: "inline-flex items-center",
|
|
16
|
+
variants: {
|
|
17
|
+
variant: { primary: "bg-primary", ghost: "bg-transparent", link: "underline" },
|
|
18
|
+
size: { sm: "h-8", md: "h-10", lg: "h-12" },
|
|
19
|
+
},
|
|
20
|
+
defaultVariants: { variant: "primary", size: "md" },
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
23
|
+
export type ButtonVariants = NonNullable<Parameters<typeof buttonRecipe>[0]>;
|
|
24
|
+
export const badgeRecipe = recipe(tv({ base: "rounded-full", variants: { variant: { default: "bg-muted", info: "bg-info" } } }));
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
// Mirrors apps/minimal/ui/Recipe/ shapes — base-only (no variants) + single-variant, with per-export JSDoc.
|
|
28
|
+
const APP = `
|
|
29
|
+
import { recipe, tv } from "akanjs/ui";
|
|
30
|
+
/** 전체 화면 배경/전경. 페이지 루트 컨테이너. */
|
|
31
|
+
export const appScreen = recipe(tv({ base: "min-h-screen bg-background text-foreground" }));
|
|
32
|
+
/** 챗 버블 — 수신/발신 방향에 따라 정렬·색을 바꾼다. */
|
|
33
|
+
export const chatBubbleRecipe = recipe(
|
|
34
|
+
tv({ base: "max-w-[78%] rounded-3xl", variants: { side: { incoming: "bg-muted", outgoing: "ml-auto bg-primary" } }, defaultVariants: { side: "incoming" } }),
|
|
35
|
+
);
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
// A docs page: the ONLY real code is a layout div; a recipe "definition" lives inside a template-literal code sample.
|
|
39
|
+
const DOCS_TSX = `
|
|
40
|
+
import { Code } from "akanjs/ui";
|
|
41
|
+
export default function Page() {
|
|
42
|
+
return (
|
|
43
|
+
<div>
|
|
44
|
+
<Code.Snippet code={\`export const fakeRecipe = recipe(tv({ base: "bg-primary", variants: { tone: { a: "x" } } }));\`} />
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
describe("scanRecipes", () => {
|
|
51
|
+
test("detects framework recipes with full variant surface", () => {
|
|
52
|
+
const recipes = scanRecipes([{ path: "recipe.ts", content: FRAMEWORK, importFrom: "akanjs/ui" }]);
|
|
53
|
+
expect(recipes.map((r) => r.name).sort()).toEqual(["badgeRecipe", "buttonRecipe"]);
|
|
54
|
+
|
|
55
|
+
const button = byName(recipes, "buttonRecipe");
|
|
56
|
+
expect(button?.importFrom).toBe("akanjs/ui");
|
|
57
|
+
expect(button?.variants.variant).toEqual(["primary", "ghost", "link"]);
|
|
58
|
+
expect(button?.variants.size).toEqual(["sm", "md", "lg"]);
|
|
59
|
+
expect(button?.defaultVariants).toEqual({ variant: "primary", size: "md" });
|
|
60
|
+
|
|
61
|
+
const badge = byName(recipes, "badgeRecipe");
|
|
62
|
+
expect(badge?.variants.variant).toEqual(["default", "info"]);
|
|
63
|
+
expect(badge?.defaultVariants).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("handles base-only recipes and captures the JSDoc one-liner", () => {
|
|
67
|
+
const recipes = scanRecipes([{ path: "Recipe.ts", content: APP, importFrom: "@apps/minimal/ui" }]);
|
|
68
|
+
|
|
69
|
+
const screen = byName(recipes, "appScreen");
|
|
70
|
+
expect(screen?.variants).toEqual({}); // base-only → empty variant surface
|
|
71
|
+
expect(screen?.doc).toBe("전체 화면 배경/전경. 페이지 루트 컨테이너.");
|
|
72
|
+
expect(screen?.importFrom).toBe("@apps/minimal/ui");
|
|
73
|
+
|
|
74
|
+
const bubble = byName(recipes, "chatBubbleRecipe");
|
|
75
|
+
expect(bubble?.variants.side).toEqual(["incoming", "outgoing"]);
|
|
76
|
+
expect(bubble?.doc).toBe("챗 버블 — 수신/발신 방향에 따라 정렬·색을 바꾼다.");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("does NOT match recipe definitions inside string/template literals (docs code samples)", () => {
|
|
80
|
+
const recipes = scanRecipes([{ path: "ui-recipe.tsx", content: DOCS_TSX, importFrom: "@apps/akan/ui" }]);
|
|
81
|
+
expect(recipes).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("skips recipe() calls whose argument is not tv(...)", () => {
|
|
85
|
+
const src = `export const x = recipe(buildStyles());\nexport const y = recipe(tv({ base: "a" }));`;
|
|
86
|
+
const recipes = scanRecipes([{ path: "f.ts", content: src, importFrom: "@x" }]);
|
|
87
|
+
expect(recipes.map((r) => r.name)).toEqual(["y"]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("ignores non-exported recipe consts and merges multiple sources", () => {
|
|
91
|
+
const src = `const hidden = recipe(tv({ base: "a" }));\nexport const shown = recipe(tv({ base: "b" }));`;
|
|
92
|
+
const recipes = scanRecipes([
|
|
93
|
+
{ path: "a.ts", content: src, importFrom: "@a" },
|
|
94
|
+
{ path: "b.ts", content: `export const other = recipe(tv({ base: "c" }));`, importFrom: "@b" },
|
|
95
|
+
]);
|
|
96
|
+
expect(recipes.map((r) => `${r.name}@${r.importFrom}`).sort()).toEqual(["other@@b", "shown@@a"]);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Recipes moved from a flat `ui/Recipe.ts` to a `ui/Recipe/` folder. Three consumers (the AGENTS.md recipe
|
|
101
|
+
// index, the recipeGate lint, the MCP module context) go through collectRecipeSources, and every one of them
|
|
102
|
+
// degrades silently — empty list, no error — if it stops finding sources. These tests are that alarm.
|
|
103
|
+
// The advisory exists to catch a look being re-authored inline. Requiring every base token only matched a
|
|
104
|
+
// verbatim copy of the whole base — the one shape that never occurs in practice — so it reported nothing on
|
|
105
|
+
// the near-copies it was built for, and silently, being advisory. These tests pin the ratio behaviour.
|
|
106
|
+
describe("findInlineRecipeDuplicates", () => {
|
|
107
|
+
// 8 tokens → ceil(8 * 0.7) = 6 must be reproduced.
|
|
108
|
+
const EIGHT = `export const cardRecipe = recipe(tv({ base: "flex rounded-box border border-border bg-card p-4 text-card-foreground shadow-sm" }));`;
|
|
109
|
+
const THREE = `export const gridRecipe = recipe(tv({ base: "grid gap-3 xl:grid-cols-2" }));`;
|
|
110
|
+
const recipesOf = (src: string) => scanRecipes([{ path: "Recipe.ts", content: src, importFrom: "@apps/x/ui" }]);
|
|
111
|
+
const hits = (src: string, jsx: string) =>
|
|
112
|
+
findInlineRecipeDuplicates(recipesOf(src), [{ path: "Page.tsx", content: jsx }]).map((d) => d.recipe);
|
|
113
|
+
|
|
114
|
+
test("flags a verbatim re-author of the whole base", () => {
|
|
115
|
+
const jsx = `<div className="flex rounded-box border border-border bg-card p-4 text-card-foreground shadow-sm" />`;
|
|
116
|
+
expect(hits(EIGHT, jsx)).toEqual(["cardRecipe"]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("flags a near-copy that drops two tokens — the case the exact-match rule missed", () => {
|
|
120
|
+
const jsx = `<div className="flex rounded-box border border-border bg-card p-4" />`;
|
|
121
|
+
expect(hits(EIGHT, jsx)).toEqual(["cardRecipe"]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("ignores a className that merely shares a few generic utilities", () => {
|
|
125
|
+
expect(hits(EIGHT, `<div className="flex border p-4" />`)).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("still requires every token of a minimum-length fingerprint", () => {
|
|
129
|
+
expect(hits(THREE, `<div className="grid gap-3 xl:grid-cols-2" />`)).toEqual(["gridRecipe"]);
|
|
130
|
+
expect(hits(THREE, `<div className="grid gap-3" />`)).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("does not flag a className that consumes the recipe", () => {
|
|
134
|
+
expect(hits(EIGHT, `<div className={cardRecipe({}, "w-full")} />`)).toEqual([]);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("collectRecipeSources", () => {
|
|
139
|
+
const seed = async (files: Record<string, string>) => {
|
|
140
|
+
const root = await mkdtemp(path.join(tmpdir(), "akan-recipe-"));
|
|
141
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
142
|
+
const abs = path.join(root, rel);
|
|
143
|
+
await Bun.write(abs, content);
|
|
144
|
+
}
|
|
145
|
+
return root;
|
|
146
|
+
};
|
|
147
|
+
const recipeSrc = (name: string) => `export const ${name} = recipe(tv({ base: "a" }));`;
|
|
148
|
+
|
|
149
|
+
test("reads every recipe file in the folder, skipping index and tests", async () => {
|
|
150
|
+
const root = await seed({
|
|
151
|
+
"ui/Recipe/index.ts": `export * from "./appCard";`,
|
|
152
|
+
"ui/Recipe/appCard.ts": recipeSrc("appCard"),
|
|
153
|
+
"ui/Recipe/appBox.ts": recipeSrc("appBox"),
|
|
154
|
+
"ui/Recipe/appBox.test.ts": recipeSrc("shouldBeSkipped"),
|
|
155
|
+
"ui/Recipe/notes.md": "ignored",
|
|
156
|
+
});
|
|
157
|
+
const sources = await collectRecipeSources(path.join(root, "ui"), "@apps/x/ui");
|
|
158
|
+
expect(sources).toHaveLength(2);
|
|
159
|
+
expect(
|
|
160
|
+
scanRecipes(sources)
|
|
161
|
+
.map((r) => r.name)
|
|
162
|
+
.sort(),
|
|
163
|
+
).toEqual(["appBox", "appCard"]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("still reads a flat Recipe.ts so an unmigrated app keeps working", async () => {
|
|
167
|
+
const root = await seed({ "ui/Recipe.ts": recipeSrc("legacy") });
|
|
168
|
+
const sources = await collectRecipeSources(path.join(root, "ui"), "@apps/x/ui");
|
|
169
|
+
expect(scanRecipes(sources).map((r) => r.name)).toEqual(["legacy"]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("honours the framework's lowercase basename", async () => {
|
|
173
|
+
const root = await seed({ "ui/recipe/buttonRecipe.ts": recipeSrc("buttonRecipe") });
|
|
174
|
+
const sources = await collectRecipeSources(path.join(root, "ui"), "akanjs/ui", "recipe");
|
|
175
|
+
expect(scanRecipes(sources).map((r) => r.name)).toEqual(["buttonRecipe"]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("returns nothing when neither shape exists, without throwing", async () => {
|
|
179
|
+
const root = await mkdtemp(path.join(tmpdir(), "akan-recipe-"));
|
|
180
|
+
await writeFile(path.join(root, "placeholder"), "");
|
|
181
|
+
expect(await collectRecipeSources(path.join(root, "ui"), "@apps/x/ui")).toEqual([]);
|
|
182
|
+
});
|
|
183
|
+
});
|
package/recipeScanner.ts
ADDED
|
@@ -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
|
+
};
|