@akanjs/devkit 3.1.1-rc.8 → 3.1.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/CHANGELOG.md +117 -0
- package/agentAssets.test.ts +65 -0
- package/agentAssets.ts +166 -0
- package/agentsIndex.ts +4 -4
- package/akanApp/akanApp.host.test.ts +12 -0
- package/akanApp/akanApp.host.ts +12 -0
- package/akanConfig/akanConfig.test.ts +30 -0
- package/akanConfig/akanConfig.ts +10 -0
- package/akanConfig/productionLockfile.test.ts +31 -0
- package/akanConfig/productionLockfile.ts +2 -0
- package/akanContext.ts +129 -4
- package/applicationBuildRunner.test.ts +17 -0
- package/applicationBuildRunner.ts +3 -1
- package/biome.base.json +4 -1
- package/cloud/cloudUpdates.ts +3 -2
- package/codeAgent/agent/CodeAgent.ts +4 -0
- package/codeAgent/agent/SubagentPool.ts +5 -0
- package/codeAgent/agent/akanSystemPrompt.ts +1 -1
- package/codeAgent/agent/codeAgent.test.ts +41 -2
- package/codeAgent/feedback/PreviewFeedback.ts +2 -2
- package/codeAgent/feedback/VerifyFeedback.ts +3 -4
- package/codeAgent/index.ts +2 -2
- package/codeAgent/skills/akan-agent/SKILL.md +36 -0
- package/codeAgent/skills/akan-code/SKILL.md +1 -0
- package/codeAgent/skills/akan-debug/SKILL.md +40 -6
- package/codeAgent/skills/akan-deps/SKILL.md +1 -2
- package/codeAgent/skills/akan-mcp/SKILL.md +40 -0
- package/codeAgent/skills/akan-page/SKILL.md +44 -0
- package/codeAgent/skills/akan-query/SKILL.md +38 -0
- package/codeAgent/skills/akan-runtime/SKILL.md +40 -0
- package/codeAgent/skills/akan-scaffold/SKILL.md +16 -6
- package/codeAgent/skills/akan-transport/SKILL.md +40 -0
- package/codeAgent/skills/akan-validate/SKILL.md +13 -11
- package/codeAgent/tools/AkanCli.ts +2 -1
- package/codeAgent/tools/AkanCodePlugins.ts +5 -1
- package/codeAgent/tools/AkanEditScope.ts +8 -3
- package/codeAgent/tools/AkanToolPack.ts +4 -10
- package/codeAgent/tools/AkanVerifier.ts +13 -31
- package/codeAgent/tools/akanCodePlugins.test.ts +60 -58
- package/commandDecorators/workspaceVersionCheck.test.ts +17 -7
- package/commandDecorators/workspaceVersionCheck.ts +4 -1
- package/commentDensityScanner.test.ts +121 -0
- package/commentDensityScanner.ts +101 -0
- package/componentSizeScanner.test.ts +36 -0
- package/componentSizeScanner.ts +52 -0
- package/componentWriteChainScanner.test.ts +137 -0
- package/componentWriteChainScanner.ts +104 -0
- package/conventionSet.test.ts +73 -0
- package/conventionSet.ts +67 -0
- package/csrE2e/ssrRegistry.e2e.test.ts +6 -1
- package/deprecatedCommentScanner.test.ts +54 -0
- package/deprecatedCommentScanner.ts +30 -0
- package/endpointReach.test.ts +58 -0
- package/endpointReach.ts +80 -0
- package/executors.test.ts +0 -4
- package/executors.ts +18 -2
- package/index.ts +1 -0
- package/integration/devStability.integration.test.ts +9 -5
- package/kindBranchScanner.test.ts +89 -0
- package/kindBranchScanner.ts +158 -0
- package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
- package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
- package/lint/no-oversized-list-limit.grit +73 -0
- package/linter.test.ts +12 -0
- package/linter.ts +35 -12
- package/maintainabilityBaseline.test.ts +280 -0
- package/maintainabilityBaseline.ts +220 -0
- package/maintainabilityContract.test.ts +846 -0
- package/maintainabilityContract.ts +404 -0
- package/maintainabilityExceptionScanner.test.ts +90 -0
- package/maintainabilityExceptionScanner.ts +85 -0
- package/maintainabilityLimits.ts +60 -0
- package/maintainabilityReport.test.ts +130 -0
- package/maintainabilityReport.ts +153 -0
- package/mobile/desktopRelease.test.ts +94 -23
- package/mobile/desktopRelease.ts +83 -45
- package/mobile/nativeApi.test.ts +4 -1
- package/mobile/nativeApi.ts +3 -3
- package/mobile/nativeApp.test.ts +2 -2
- package/mobile/nativeApp.ts +18 -11
- package/mobile/nativeConfig.test.ts +16 -8
- package/mobile/nativeConfig.ts +1 -1
- package/mobile/updateUploader.test.ts +34 -0
- package/mobile/updateUploader.ts +9 -6
- package/package.json +3 -2
- package/platformTest/LinuxDockerTarget.ts +3 -0
- package/{codeAgent/feedback → preview}/PreviewChrome.ts +13 -7
- package/preview/PreviewView.ts +219 -0
- package/preview/index.ts +2 -0
- package/preview/previewView.test.ts +76 -0
- package/prompter.ts +7 -0
- package/qualityScanner.test.ts +75 -0
- package/qualityScanner.ts +17 -1
- package/routeSourceValidator.test.ts +11 -2
- package/routeSourceValidator.ts +14 -2
- package/serviceLoopCallScanner.test.ts +56 -0
- package/serviceLoopCallScanner.ts +122 -0
- package/signalMutationIndex.test.ts +15 -0
- package/signalMutationIndex.ts +1 -1
- package/sourceComments.test.ts +45 -0
- package/sourceComments.ts +70 -0
- package/storeAccessScanner.test.ts +84 -0
- package/storeAccessScanner.ts +71 -0
- package/storeSource.ts +159 -0
- package/storeWriteScanner.test.ts +292 -0
- package/storeWriteScanner.ts +50 -0
- package/testOnlyExportScanner.test.ts +83 -0
- package/testOnlyExportScanner.ts +124 -0
- package/typeChecker.ts +32 -0
- package/typecheck/projectTypecheck.test.ts +18 -0
- package/typecheck/projectTypecheck.ts +29 -5
- package/typecheck/typecheck.proc.ts +6 -0
- package/typecheck/typecheckFailure.ts +13 -0
- package/types.ts +1 -0
- package/workflow/artifacts.ts +16 -2
- package/workflow/executor.ts +22 -7
- package/workflow/guardCatalog.test.ts +59 -0
- package/workflow/guardCatalog.ts +108 -0
- package/workflow/index.ts +4 -0
- package/workflow/interactionScaffold.ts +122 -0
- package/workflow/mutationScaffold.ts +519 -0
- package/workflow/mutationSource.test.ts +108 -0
- package/workflow/mutationSource.ts +219 -0
- package/workflow/plan.ts +54 -0
- package/workflow/render.ts +19 -4
- package/workflow/source.test.ts +185 -0
- package/workflow/source.ts +151 -24
- package/workflow/types.ts +18 -1
- package/workspaceLayout.ts +2 -0
- package/writePath.test.ts +109 -0
- package/writePath.ts +219 -0
- package/writePlacementScanner.test.ts +77 -0
- package/writePlacementScanner.ts +50 -0
- package/writingActionIndex.test.ts +99 -0
- package/writingActionIndex.ts +63 -0
- package/codeAgent/feedback/PreviewView.ts +0 -95
- /package/{codeAgent/feedback → preview}/previewChrome.test.ts +0 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { PreviewChrome, type PreviewEngine } from "./PreviewChrome";
|
|
2
|
+
|
|
3
|
+
export interface PreviewProbeOptions {
|
|
4
|
+
screenshot?: boolean;
|
|
5
|
+
width?: number;
|
|
6
|
+
height?: number;
|
|
7
|
+
engine?: PreviewEngine;
|
|
8
|
+
/** Bounds the whole probe: a dev server that is recovering takes the connection and never answers. */
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
/** Waited after the load, so the errors effects throw land before the page is read. */
|
|
11
|
+
settleMs?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PreviewProbeResult {
|
|
15
|
+
url: string;
|
|
16
|
+
finalUrl: string;
|
|
17
|
+
status: number | null;
|
|
18
|
+
title: string;
|
|
19
|
+
text: string;
|
|
20
|
+
consoleErrors: string[];
|
|
21
|
+
consoleWarnings: string[];
|
|
22
|
+
pageErrors: string[];
|
|
23
|
+
/** Cheap structural smells that read as "the page is broken" without anyone looking at it. */
|
|
24
|
+
domFindings: string[];
|
|
25
|
+
screenshot?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface WebViewLike {
|
|
29
|
+
navigate(url: string): Promise<void>;
|
|
30
|
+
evaluate(expression: string): Promise<unknown>;
|
|
31
|
+
screenshot(options: { encoding: "base64" }): Promise<string>;
|
|
32
|
+
close(): void | Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface PageRead {
|
|
36
|
+
href: string;
|
|
37
|
+
title: string;
|
|
38
|
+
text: string;
|
|
39
|
+
status: number | null;
|
|
40
|
+
empty: boolean;
|
|
41
|
+
pageErrors: string[];
|
|
42
|
+
domFindings: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Bun.WebView rather than a downloaded browser: on macOS its webkit backend needs no Chromium at all.
|
|
46
|
+
// It is experimental, so its whole surface stays behind this adapter; a Bun minor that changes it breaks one file.
|
|
47
|
+
export class PreviewView {
|
|
48
|
+
static get available() {
|
|
49
|
+
return PreviewView.isAvailable();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
static isAvailable(engine: PreviewEngine = PreviewChrome.backend) {
|
|
53
|
+
return typeof (Bun as unknown as { WebView?: unknown }).WebView === "function" && PreviewChrome.isAvailable(engine);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static unavailableReason(engine: PreviewEngine = PreviewChrome.backend) {
|
|
57
|
+
if (typeof (Bun as unknown as { WebView?: unknown }).WebView !== "function")
|
|
58
|
+
return "This Bun build has no WebView, so the page preview is off.";
|
|
59
|
+
return PreviewChrome.unavailableReason(engine);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
static async probe(
|
|
63
|
+
url: string,
|
|
64
|
+
{
|
|
65
|
+
screenshot = false,
|
|
66
|
+
width = 1280,
|
|
67
|
+
height = 800,
|
|
68
|
+
engine = PreviewChrome.backend,
|
|
69
|
+
timeoutMs = 30_000,
|
|
70
|
+
settleMs = 0,
|
|
71
|
+
}: PreviewProbeOptions = {},
|
|
72
|
+
): Promise<PreviewProbeResult> {
|
|
73
|
+
const logs: string[] = [];
|
|
74
|
+
const deadline = Date.now() + timeoutMs;
|
|
75
|
+
const within = <T>(work: Promise<T>, what: string) => PreviewView.#within(work, deadline, `${what} ${url}`);
|
|
76
|
+
const view = PreviewView.#open({ width, height, engine, onConsole: (line) => logs.push(line) });
|
|
77
|
+
try {
|
|
78
|
+
let page = await PreviewView.#load(view, url, settleMs, within);
|
|
79
|
+
//? Right after a save the first load can render nothing (`[rscClient] module not loaded`); the next one has it.
|
|
80
|
+
if (page.empty) {
|
|
81
|
+
logs.length = 0;
|
|
82
|
+
page = await PreviewView.#load(view, url, settleMs, within);
|
|
83
|
+
}
|
|
84
|
+
const result: PreviewProbeResult = {
|
|
85
|
+
url,
|
|
86
|
+
finalUrl: page.href,
|
|
87
|
+
status: page.status ?? (await PreviewView.#statusOf(page.href, deadline)),
|
|
88
|
+
title: page.title,
|
|
89
|
+
text: page.text,
|
|
90
|
+
consoleErrors: logs.filter((line) => line.startsWith("error")),
|
|
91
|
+
consoleWarnings: logs.filter((line) => line.startsWith("warn")),
|
|
92
|
+
pageErrors: page.pageErrors,
|
|
93
|
+
domFindings: page.domFindings,
|
|
94
|
+
};
|
|
95
|
+
if (screenshot) result.screenshot = await within(view.screenshot({ encoding: "base64" }), "screenshotting");
|
|
96
|
+
return result;
|
|
97
|
+
} finally {
|
|
98
|
+
await view.close();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
static async #load(
|
|
103
|
+
view: WebViewLike,
|
|
104
|
+
url: string,
|
|
105
|
+
settleMs: number,
|
|
106
|
+
within: <T>(work: Promise<T>, what: string) => Promise<T>,
|
|
107
|
+
): Promise<PageRead> {
|
|
108
|
+
await within(view.navigate(url), "loading");
|
|
109
|
+
if (settleMs > 0) await Bun.sleep(settleMs);
|
|
110
|
+
const read = (await within(view.evaluate(readPageExpression), "reading")) as Partial<PageRead> | null;
|
|
111
|
+
return {
|
|
112
|
+
href: typeof read?.href === "string" ? read.href : url,
|
|
113
|
+
title: typeof read?.title === "string" ? read.title : "",
|
|
114
|
+
text: typeof read?.text === "string" ? read.text : "",
|
|
115
|
+
status: typeof read?.status === "number" && read.status > 0 ? read.status : null,
|
|
116
|
+
empty: read?.empty === true,
|
|
117
|
+
pageErrors: PreviewView.#strings(read?.pageErrors),
|
|
118
|
+
domFindings: PreviewView.#strings(read?.domFindings),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
//? webkit has no Navigation Timing `responseStatus`, so the status comes from asking the server again, unredirected.
|
|
123
|
+
static async #statusOf(href: string, deadline: number) {
|
|
124
|
+
const left = Math.max(1, deadline - Date.now());
|
|
125
|
+
const response = await fetch(href, { redirect: "manual", signal: AbortSignal.timeout(left) }).catch(() => null);
|
|
126
|
+
return response?.status ?? null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
static async #within<T>(work: Promise<T>, deadline: number, what: string): Promise<T> {
|
|
130
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
131
|
+
const expired = new Promise<never>((_, reject) => {
|
|
132
|
+
timer = setTimeout(
|
|
133
|
+
() => reject(new Error(`Timed out ${what}; the dev server may be rebuilding or stuck.`)),
|
|
134
|
+
Math.max(0, deadline - Date.now()),
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
try {
|
|
138
|
+
return await Promise.race([work, expired]);
|
|
139
|
+
} finally {
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
static #open({
|
|
145
|
+
width,
|
|
146
|
+
height,
|
|
147
|
+
engine,
|
|
148
|
+
onConsole,
|
|
149
|
+
}: {
|
|
150
|
+
width: number;
|
|
151
|
+
height: number;
|
|
152
|
+
engine: PreviewEngine;
|
|
153
|
+
onConsole: (line: string) => void;
|
|
154
|
+
}) {
|
|
155
|
+
const Ctor = (Bun as unknown as { WebView: new (options: unknown) => WebViewLike }).WebView;
|
|
156
|
+
return new Ctor({
|
|
157
|
+
width,
|
|
158
|
+
height,
|
|
159
|
+
dataStore: "ephemeral",
|
|
160
|
+
backend: PreviewChrome.backendOption(engine),
|
|
161
|
+
// The console option is a callback taking `(type, ...args)`, not an object of console methods.
|
|
162
|
+
console: (type: string, ...args: unknown[]) => onConsole(`${type} ${PreviewView.formatConsole(args)}`),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Substitutes `%s` as the console does, since React passes a warning's detail as arguments, and cuts it short. */
|
|
167
|
+
static formatConsole(args: unknown[]) {
|
|
168
|
+
const [first, ...rest] = args;
|
|
169
|
+
const head =
|
|
170
|
+
typeof first === "string"
|
|
171
|
+
? first.replace(/%[sdifoOc]/g, (token) => {
|
|
172
|
+
if (!rest.length) return token;
|
|
173
|
+
const value = rest.shift();
|
|
174
|
+
return token === "%c" ? "" : String(value);
|
|
175
|
+
})
|
|
176
|
+
: String(first);
|
|
177
|
+
return [head, ...rest.map(String)]
|
|
178
|
+
.join(" ")
|
|
179
|
+
.replace(/\n(\s*\n)+/g, "\n")
|
|
180
|
+
.slice(0, 1_500);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
static #strings(value: unknown) {
|
|
184
|
+
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// evaluate() takes one expression (a statement list is a SyntaxError), hence the IIFE; one call in flight per view.
|
|
189
|
+
// `view.title` is only refreshed by a navigation, so the title is read from the document.
|
|
190
|
+
const readPageExpression = `(() => {
|
|
191
|
+
const pageErrors = [];
|
|
192
|
+
const domFindings = [];
|
|
193
|
+
const body = document.body;
|
|
194
|
+
const rendered = body ? Array.from(body.children).filter((el) => !["SCRIPT", "STYLE", "TEMPLATE", "LINK", "NOSCRIPT"].includes(el.tagName) && !el.classList.contains("__akan_hmr_overlay")) : [];
|
|
195
|
+
const empty = !rendered.length && !(body && body.innerText && body.innerText.trim());
|
|
196
|
+
if (empty) pageErrors.push("The page body rendered empty.");
|
|
197
|
+
if (self.__AKAN_HMR_SYSTEM_PAGE__) pageErrors.push("The server could not render this page and answered with its error page: " + (body ? body.innerText.trim().replace(/\\s+/g, " ").slice(0, 400) : ""));
|
|
198
|
+
const overlay = document.querySelector('.__akan_hmr_overlay[data-status="error"]');
|
|
199
|
+
if (overlay) pageErrors.push("The dev build failed: " + overlay.textContent.trim().slice(0, 400));
|
|
200
|
+
const all = Array.from(document.querySelectorAll("body *"));
|
|
201
|
+
const viewportWidth = document.documentElement.clientWidth;
|
|
202
|
+
const pageWidth = document.documentElement.scrollWidth;
|
|
203
|
+
if (pageWidth > viewportWidth + 8) {
|
|
204
|
+
const wide = all.find((el) => el.getBoundingClientRect().right > viewportWidth + 8);
|
|
205
|
+
domFindings.push("The page is " + pageWidth + "px wide in a " + viewportWidth + "px viewport" + (wide ? ", first wider element: " + (wide.tagName.toLowerCase() + "." + (typeof wide.className === "string" ? wide.className : "")).slice(0, 120) : "") + ".");
|
|
206
|
+
}
|
|
207
|
+
const invisibleText = all.filter((el) => {
|
|
208
|
+
if (!el.textContent || !el.textContent.trim()) return false;
|
|
209
|
+
const style = getComputedStyle(el);
|
|
210
|
+
return style.color === style.backgroundColor && style.backgroundColor !== "rgba(0, 0, 0, 0)";
|
|
211
|
+
});
|
|
212
|
+
if (invisibleText.length) domFindings.push(invisibleText.length + " element(s) render text in the same colour as their background.");
|
|
213
|
+
const unresolved = all.filter((el) => /(^|\\s)(bg|text|border)-(base-100|base-content|primary-content|error)(\\s|$)/.test(typeof el.className === "string" ? el.className : ""));
|
|
214
|
+
if (unresolved.length) domFindings.push(unresolved.length + " element(s) use a dropped daisyUI colour slot that renders no CSS.");
|
|
215
|
+
const navigation = performance.getEntriesByType("navigation")[0];
|
|
216
|
+
const status = navigation && typeof navigation.responseStatus === "number" ? navigation.responseStatus : null;
|
|
217
|
+
const text = body ? body.innerText.trim().replace(/\\s+/g, " ").slice(0, 600) : "";
|
|
218
|
+
return { href: location.href, title: document.title, text, status, empty, pageErrors, domFindings };
|
|
219
|
+
})()`;
|
package/preview/index.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { PreviewView } from "./PreviewView";
|
|
3
|
+
|
|
4
|
+
const serve = (html: string, status = 200) =>
|
|
5
|
+
Bun.serve({ port: 0, fetch: () => new Response(html, { status, headers: { "content-type": "text/html" } }) });
|
|
6
|
+
|
|
7
|
+
describe("PreviewView", () => {
|
|
8
|
+
test("reports whether this Bun build can drive a headless page at all", () => {
|
|
9
|
+
expect(typeof PreviewView.available).toBe("boolean");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("formats a console call the way the console prints it", () => {
|
|
13
|
+
expect(PreviewView.formatConsole(["%cstyled%c %s of %d", "color:red", "", "two", 3, "extra"])).toBe(
|
|
14
|
+
"styled two of 3 extra",
|
|
15
|
+
);
|
|
16
|
+
expect(PreviewView.formatConsole(["a\n\n\n \nb"])).toBe("a\nb");
|
|
17
|
+
expect(PreviewView.formatConsole(["x".repeat(2_000)])).toHaveLength(1_500);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test.skipIf(!PreviewView.available)(
|
|
21
|
+
"finds an empty body and a console error on a real page",
|
|
22
|
+
async () => {
|
|
23
|
+
const server = serve('<!doctype html><html><body></body><script>console.error("boom")</script></html>');
|
|
24
|
+
try {
|
|
25
|
+
const probe = await PreviewView.probe(`http://localhost:${server.port}/`);
|
|
26
|
+
expect(probe.pageErrors.join(" ")).toContain("rendered empty");
|
|
27
|
+
expect(probe.consoleErrors.join(" ")).toContain("boom");
|
|
28
|
+
} finally {
|
|
29
|
+
server.stop(true);
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
30_000,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
test.skipIf(!PreviewView.available)(
|
|
36
|
+
"reads the status, the live title, a failed dev build and a page wider than the viewport",
|
|
37
|
+
async () => {
|
|
38
|
+
const server = serve(
|
|
39
|
+
[
|
|
40
|
+
"<!doctype html><html><head><title>Before</title></head><body>",
|
|
41
|
+
'<div style="width:900px">wide</div>',
|
|
42
|
+
'<div class="__akan_hmr_overlay" data-status="error">Build failed: page.tsx</div>',
|
|
43
|
+
'<script>document.title = "After";</script>',
|
|
44
|
+
"</body></html>",
|
|
45
|
+
].join(""),
|
|
46
|
+
404,
|
|
47
|
+
);
|
|
48
|
+
try {
|
|
49
|
+
const probe = await PreviewView.probe(`http://localhost:${server.port}/missing`, { width: 390, height: 844 });
|
|
50
|
+
expect(probe.status).toBe(404);
|
|
51
|
+
expect(probe.title).toBe("After");
|
|
52
|
+
expect(probe.text).toBe("wide Build failed: page.tsx");
|
|
53
|
+
expect(probe.pageErrors).toEqual(["The dev build failed: Build failed: page.tsx"]);
|
|
54
|
+
expect(probe.domFindings.join(" ")).toContain("first wider element: div");
|
|
55
|
+
} finally {
|
|
56
|
+
server.stop(true);
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
30_000,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
test.skipIf(!PreviewView.available)(
|
|
63
|
+
"gives up on a server that takes the connection and never answers",
|
|
64
|
+
async () => {
|
|
65
|
+
const server = Bun.serve({ port: 0, fetch: () => new Promise<Response>(() => undefined) });
|
|
66
|
+
try {
|
|
67
|
+
await expect(PreviewView.probe(`http://localhost:${server.port}/`, { timeoutMs: 1_500 })).rejects.toThrow(
|
|
68
|
+
"Timed out loading",
|
|
69
|
+
);
|
|
70
|
+
} finally {
|
|
71
|
+
server.stop(true);
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
30_000,
|
|
75
|
+
);
|
|
76
|
+
});
|
package/prompter.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fsPromise from "node:fs/promises";
|
|
2
2
|
import { input, select } from "@inquirer/prompts";
|
|
3
3
|
|
|
4
|
+
import { ConventionSet } from "./conventionSet";
|
|
4
5
|
import { getDirname } from "./fileSys";
|
|
5
6
|
|
|
6
7
|
interface FileUpdateRequestProps {
|
|
@@ -40,8 +41,14 @@ export class Prompter {
|
|
|
40
41
|
if (!guideNames.includes(guideName))
|
|
41
42
|
throw new Error(`Unknown guideline "${guideName}". Available: ${guideNames.join(", ")}`);
|
|
42
43
|
const guidelineRoot = await Prompter.#getGuidelineRoot();
|
|
44
|
+
const composed = `${guidelineRoot}/${guideName}/core.md`;
|
|
45
|
+
if (await Bun.file(composed).exists())
|
|
46
|
+
return (await ConventionSet.load(`${guidelineRoot}/${guideName}`)).instruction;
|
|
43
47
|
return await fsPromise.readFile(`${guidelineRoot}/${guideName}/${guideName}.instruction.md`, "utf-8");
|
|
44
48
|
}
|
|
49
|
+
static async getConventionSet() {
|
|
50
|
+
return await ConventionSet.load(`${await Prompter.#getGuidelineRoot()}/conventions`);
|
|
51
|
+
}
|
|
45
52
|
static async getUpdateRequest(guideName: string) {
|
|
46
53
|
return await input({
|
|
47
54
|
message: `What do you want to update in ${guideName}?`,
|
package/qualityScanner.test.ts
CHANGED
|
@@ -355,3 +355,78 @@ describe("bang comments in browser-reachable files", () => {
|
|
|
355
355
|
);
|
|
356
356
|
});
|
|
357
357
|
});
|
|
358
|
+
|
|
359
|
+
describe("AkanQualityScanner comment and dead-export rules", () => {
|
|
360
|
+
test("reports comment density, a loose deprecated marker and a test-only export, each with its fix", async () => {
|
|
361
|
+
const root = await makeWorkspace({
|
|
362
|
+
"apps/demo/lib/court/court.constant.ts": [
|
|
363
|
+
...Array.from({ length: 5 }, (_, idx) => `// restates line ${idx}`),
|
|
364
|
+
"export class CourtInput {",
|
|
365
|
+
" legacyVenue?: string; // deprecated",
|
|
366
|
+
"}",
|
|
367
|
+
"",
|
|
368
|
+
].join("\n"),
|
|
369
|
+
"apps/demo/webkit/boardLayout.ts": "export const computeGroupBoxes = () => [];\n",
|
|
370
|
+
"apps/demo/webkit/boardLayout.test.ts":
|
|
371
|
+
'import { computeGroupBoxes } from "./boardLayout";\ncomputeGroupBoxes();\n',
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
const result = await new AkanQualityScanner().scan(root);
|
|
375
|
+
const density = rulesOf(result, "akan.comment.density");
|
|
376
|
+
const deprecated = rulesOf(result, "akan.comment.deprecated-marker");
|
|
377
|
+
const testOnly = rulesOf(result, "akan.global.test-only-export");
|
|
378
|
+
|
|
379
|
+
expect(density.map((warning) => warning.file)).toEqual(["apps/demo/lib/court/court.constant.ts"]);
|
|
380
|
+
expect(density[0]?.fix).toContain('AGENTS.md "Comments"');
|
|
381
|
+
expect(deprecated.map((warning) => warning.line)).toEqual([7]);
|
|
382
|
+
expect(deprecated[0]?.fix).toContain("/** @deprecated");
|
|
383
|
+
expect(testOnly.map((warning) => warning.file)).toEqual(["apps/demo/webkit/boardLayout.ts"]);
|
|
384
|
+
expect(testOnly[0]?.fix).toContain("unexported");
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
describe("AkanQualityScanner kind-branch rule", () => {
|
|
389
|
+
test("warns on a kind compared across three files and points at a kind table", async () => {
|
|
390
|
+
const compare = 'export const isScene = (pin: { kind: string }) => pin.kind === "videoScene";\n';
|
|
391
|
+
const root = await makeWorkspace({
|
|
392
|
+
"apps/demo/lib/pin/pin.constant.ts":
|
|
393
|
+
'export class PinKind extends enumOf("pinKind", ["videoScene", "memo"] as const) {}\n',
|
|
394
|
+
"apps/demo/lib/board/board.constant.ts": compare,
|
|
395
|
+
"apps/demo/lib/take/take.store.ts": compare,
|
|
396
|
+
"apps/demo/webkit/pinLayout.ts": compare,
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.global.kind-branch");
|
|
400
|
+
|
|
401
|
+
expect(warnings.map(({ file, severity }) => ({ file, severity }))).toEqual([
|
|
402
|
+
{ file: "apps/demo/lib/pin/pin.constant.ts", severity: "warning" },
|
|
403
|
+
]);
|
|
404
|
+
expect(warnings[0]?.fix).toContain('cnst.PinKind["value"]');
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
describe("AkanQualityScanner baseline exceptions", () => {
|
|
409
|
+
test("reports each app's recorded baseline entries per rule", async () => {
|
|
410
|
+
const root = await makeWorkspace({
|
|
411
|
+
"apps/demo/akan.baseline.json": `${JSON.stringify(
|
|
412
|
+
{
|
|
413
|
+
"akan.size.file": { "ui/Board.tsx": 420 },
|
|
414
|
+
reasons: { "ui/Board.tsx": "Generated SVG path table for the court." },
|
|
415
|
+
},
|
|
416
|
+
null,
|
|
417
|
+
2,
|
|
418
|
+
)}\n`,
|
|
419
|
+
"apps/demo/ui/Board.tsx": "export const Board = () => null;\n",
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.baseline.exceptions");
|
|
423
|
+
|
|
424
|
+
expect(warnings.map(({ file, message }) => ({ file, message }))).toEqual([
|
|
425
|
+
{
|
|
426
|
+
file: "apps/demo/akan.baseline.json",
|
|
427
|
+
message:
|
|
428
|
+
'apps/demo carries 1 reasoned exception and 0 adopted-debt entries for akan.size.file: ui/Board.tsx ("Generated SVG path table for the court.").',
|
|
429
|
+
},
|
|
430
|
+
]);
|
|
431
|
+
});
|
|
432
|
+
});
|
package/qualityScanner.ts
CHANGED
|
@@ -5,8 +5,13 @@ import { RESERVED_ROUTE_CONFIG_EXPORTS } from "akanjs/common";
|
|
|
5
5
|
import ignore from "ignore";
|
|
6
6
|
import ts from "typescript";
|
|
7
7
|
import { AbstractDoc } from "./abstractDoc";
|
|
8
|
+
import { CommentDensityScanner } from "./commentDensityScanner";
|
|
9
|
+
import { DeprecatedCommentScanner } from "./deprecatedCommentScanner";
|
|
8
10
|
import { FormSetterScanner } from "./formSetterScanner";
|
|
11
|
+
import { KindBranchScanner } from "./kindBranchScanner";
|
|
12
|
+
import { MaintainabilityExceptionScanner } from "./maintainabilityExceptionScanner";
|
|
9
13
|
import { fileWarning, formatSsrBalance, type SsrBalanceEntry, SsrScanner } from "./ssrScanner";
|
|
14
|
+
import { TestOnlyExportScanner } from "./testOnlyExportScanner";
|
|
10
15
|
import {
|
|
11
16
|
facetIndexShadowOf,
|
|
12
17
|
isAllowedLibFacetRootFile,
|
|
@@ -17,7 +22,7 @@ import {
|
|
|
17
22
|
} from "./workspaceLayout";
|
|
18
23
|
|
|
19
24
|
type QualitySeverity = "warning";
|
|
20
|
-
type QualityScope = "global" | "file" | "convention" | "layout" | "ssr" | "agent";
|
|
25
|
+
type QualityScope = "global" | "file" | "convention" | "layout" | "ssr" | "agent" | "comment";
|
|
21
26
|
|
|
22
27
|
export interface QualityWarning {
|
|
23
28
|
rule: string;
|
|
@@ -115,6 +120,8 @@ const RULE_FIXES: Record<string, string> = {
|
|
|
115
120
|
"Rename one of the exports, or if they are the same thing, extract it into one shared module and import it in both places.",
|
|
116
121
|
"akan.global.duplicate-exported-function-body":
|
|
117
122
|
"Extract the shared implementation into a single exported helper and import it, instead of copying the body.",
|
|
123
|
+
"akan.global.test-only-export":
|
|
124
|
+
"Delete the export together with its test. If a live caller in the same file needs it, keep it unexported and test it through that caller.",
|
|
118
125
|
"akan.file.recommended-max-lines":
|
|
119
126
|
"Split the file by responsibility — move Zones, Utils, or subcomponents into sibling files.",
|
|
120
127
|
"akan.file.max-lines": "Break the file into smaller focused modules; keep one primary responsibility per file.",
|
|
@@ -163,6 +170,10 @@ const RULE_FIXES: Record<string, string> = {
|
|
|
163
170
|
"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.",
|
|
164
171
|
"akan.ssr.template-client-state":
|
|
165
172
|
"Bind the field to the store instead: `value={xForm.field}` with `onChange={st.do.setFieldOnX}`.",
|
|
173
|
+
"akan.comment.density":
|
|
174
|
+
'Delete comments that restate the code, label a section, or narrate a step; keep only those carrying a fact the code cannot show, such as a vendor quirk or security reasoning (AGENTS.md "Comments").',
|
|
175
|
+
"akan.comment.deprecated-marker":
|
|
176
|
+
"Replace the comment with `/** @deprecated <what to use instead> */` on the declaration, so every use site shows it struck through.",
|
|
166
177
|
};
|
|
167
178
|
|
|
168
179
|
function getRuleFix(rule: string): string | undefined {
|
|
@@ -193,6 +204,9 @@ export class AkanQualityScanner {
|
|
|
193
204
|
...sourceFiles.flatMap((sourceFile) => this.#scanLayoutQuality(sourceFile)),
|
|
194
205
|
...abstractFiles.flatMap((abstractFile) => this.#scanAbstractQuality(abstractFile)),
|
|
195
206
|
...new FormSetterScanner().scan(sourceFiles),
|
|
207
|
+
...new CommentDensityScanner().scan(sourceFiles),
|
|
208
|
+
...new DeprecatedCommentScanner().scan(sourceFiles),
|
|
209
|
+
...(await new MaintainabilityExceptionScanner().scan(workspaceRoot)),
|
|
196
210
|
...ssr.warnings,
|
|
197
211
|
];
|
|
198
212
|
|
|
@@ -297,6 +311,8 @@ export class AkanQualityScanner {
|
|
|
297
311
|
});
|
|
298
312
|
}
|
|
299
313
|
|
|
314
|
+
warnings.push(...new TestOnlyExportScanner().scan(sourceFiles));
|
|
315
|
+
warnings.push(...new KindBranchScanner().scan(sourceFiles));
|
|
300
316
|
return warnings;
|
|
301
317
|
}
|
|
302
318
|
|
|
@@ -43,12 +43,14 @@ describe("RouteSourceValidator route chains", () => {
|
|
|
43
43
|
const chain = (source: string, kind: "page" | "layout", pattern: string) =>
|
|
44
44
|
RouteSourceValidator.validateRouteSourceExports(source, `page/${kind}.tsx`, kind, { pattern });
|
|
45
45
|
|
|
46
|
-
test("reads params, prompt, apps and devOnly off a page() chain without evaluating it", () => {
|
|
46
|
+
test("reads params, search names, prompt, apps, guard and devOnly off a page() chain without evaluating it", () => {
|
|
47
47
|
const source = [
|
|
48
48
|
'import { page } from "akanjs/client";',
|
|
49
49
|
"export default page()",
|
|
50
50
|
' .param("projectId", ID, { desc: "The project." })',
|
|
51
51
|
' .search("status", String)',
|
|
52
|
+
" .search(dynamicName, String)",
|
|
53
|
+
" .guard(() => signedIn())",
|
|
52
54
|
" .config({ transition: 'stack', devOnly: true })",
|
|
53
55
|
' .prompt("briefProject", "Brief one project.")',
|
|
54
56
|
' .app("showProject", "Show one project.", ({ projectId }) => null)',
|
|
@@ -57,7 +59,14 @@ describe("RouteSourceValidator route chains", () => {
|
|
|
57
59
|
].join("\n");
|
|
58
60
|
expect(chain(source, "page", "/:lang/project/:projectId")).toEqual({
|
|
59
61
|
devOnly: true,
|
|
60
|
-
chain: {
|
|
62
|
+
chain: {
|
|
63
|
+
kind: "page",
|
|
64
|
+
params: ["projectId"],
|
|
65
|
+
prompt: "briefProject",
|
|
66
|
+
apps: ["showProject", "showBoard"],
|
|
67
|
+
search: ["status"],
|
|
68
|
+
guarded: true,
|
|
69
|
+
},
|
|
61
70
|
});
|
|
62
71
|
});
|
|
63
72
|
|
package/routeSourceValidator.ts
CHANGED
|
@@ -17,6 +17,10 @@ export interface RouteSourceChain {
|
|
|
17
17
|
prompt?: string;
|
|
18
18
|
/** The `.app()` names, each one MCP App the build gives a view of its own. */
|
|
19
19
|
apps: string[];
|
|
20
|
+
/** The `.search()` names, in chain order; a name that is not a literal is left out. */
|
|
21
|
+
search: string[];
|
|
22
|
+
/** The chain calls `.guard()`, which gates this route and, on a layout, every route under it. */
|
|
23
|
+
guarded: boolean;
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
interface ChainStage {
|
|
@@ -104,6 +108,8 @@ export class RouteSourceValidator {
|
|
|
104
108
|
params: chain.params,
|
|
105
109
|
...(chain.prompt ? { prompt: chain.prompt } : {}),
|
|
106
110
|
apps: chain.apps,
|
|
111
|
+
search: chain.search,
|
|
112
|
+
guarded: chain.guarded,
|
|
107
113
|
},
|
|
108
114
|
};
|
|
109
115
|
}
|
|
@@ -168,8 +174,10 @@ export class RouteSourceValidator {
|
|
|
168
174
|
static #chainOf(kind: RouteSourceChain["kind"], stages: ChainStage[], filePath: string) {
|
|
169
175
|
const params: string[] = [];
|
|
170
176
|
const apps: string[] = [];
|
|
177
|
+
const search: string[] = [];
|
|
171
178
|
let prompt: string | undefined;
|
|
172
179
|
let devOnly = false;
|
|
180
|
+
let guarded = false;
|
|
173
181
|
const literal = (stage: ChainStage) => {
|
|
174
182
|
const first = stage.args[0];
|
|
175
183
|
if (!first || !ts.isStringLiteralLike(first))
|
|
@@ -182,10 +190,14 @@ export class RouteSourceValidator {
|
|
|
182
190
|
if (stage.name === "param") params.push(literal(stage));
|
|
183
191
|
else if (stage.name === "prompt") prompt = literal(stage);
|
|
184
192
|
else if (stage.name === "app") apps.push(literal(stage));
|
|
185
|
-
else if (stage.name === "
|
|
193
|
+
else if (stage.name === "guard") guarded = true;
|
|
194
|
+
else if (stage.name === "search") {
|
|
195
|
+
const first = stage.args[0];
|
|
196
|
+
if (first && ts.isStringLiteralLike(first)) search.push(first.text);
|
|
197
|
+
} else if (stage.name === "config")
|
|
186
198
|
devOnly = RouteSourceValidator.#devOnlyOf(RouteSourceValidator.#unwrapExpression(stage.args[0]), filePath);
|
|
187
199
|
}
|
|
188
|
-
return { kind, params, prompt, apps, devOnly };
|
|
200
|
+
return { kind, params, prompt, apps, search, guarded, devOnly };
|
|
189
201
|
}
|
|
190
202
|
|
|
191
203
|
/** The same rule the loader applies: a page names every `[x]` it sits under, a layout may name a subset. */
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { ServiceLoopCallScanner } from "./serviceLoopCallScanner";
|
|
4
|
+
|
|
5
|
+
const scan = (body: string, answersCaller: (method: string) => boolean = () => true) =>
|
|
6
|
+
new ServiceLoopCallScanner().scan(
|
|
7
|
+
ts.createSourceFile(
|
|
8
|
+
"court.service.ts",
|
|
9
|
+
`export class CourtService extends serve(db.court, () => ({})) {\n${body}\n}\n`,
|
|
10
|
+
ts.ScriptTarget.Latest,
|
|
11
|
+
true,
|
|
12
|
+
),
|
|
13
|
+
answersCaller,
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
describe("ServiceLoopCallScanner", () => {
|
|
17
|
+
test("names a service, model or generated query called per item, directly or through a method of its own", () => {
|
|
18
|
+
const calls = scan(` async syncAll(ids: string[]) {
|
|
19
|
+
for (const id of await this.listIds()) await this.pcService.ping(id);
|
|
20
|
+
await Promise.all(ids.map((id) => this.#touch(id)));
|
|
21
|
+
ids.forEach((id) => this.logger.info(id));
|
|
22
|
+
}
|
|
23
|
+
async #touch(id: string) {
|
|
24
|
+
await this.getCourt(id);
|
|
25
|
+
}`);
|
|
26
|
+
|
|
27
|
+
expect(calls).toEqual([{ method: "syncAll", line: 3, calls: ["this.pcService.ping", "this.#touch"] }]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("spares a counted loop, a call made once, and a method no endpoint reaches", () => {
|
|
31
|
+
const body = ` async retry(id: string) {
|
|
32
|
+
for (let attempt = 0; attempt < 3; attempt += 1) await this.courtModel.touch(id);
|
|
33
|
+
while (await this.courtModel.isLocked(id)) await Bun.sleep(10);
|
|
34
|
+
}
|
|
35
|
+
async sweep(ids: string[]) {
|
|
36
|
+
const courts = await this.loadCourtMany(ids);
|
|
37
|
+
for (const court of courts) court.touch();
|
|
38
|
+
await Promise.all(ids.map((id) => this.pcService.ping(id)));
|
|
39
|
+
}`;
|
|
40
|
+
|
|
41
|
+
expect(scan(body, (method) => method === "retry")).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("spares a get or load a per-element callback issues in one tick, which the DataLoader batches", () => {
|
|
45
|
+
const calls = scan(` async courtsOf(ids: string[], pcIds: string[]) {
|
|
46
|
+
await Promise.all(ids.map((id) => this.getCourt(id)));
|
|
47
|
+
await Promise.all(pcIds.map((id) => this.pcModel.loadPc(id)));
|
|
48
|
+
await Promise.all(ids.map((id) => this.courtModel.countCourt({ id })));
|
|
49
|
+
for (const id of ids) await this.courtModel.getCourt(id);
|
|
50
|
+
}`);
|
|
51
|
+
|
|
52
|
+
expect(calls).toEqual([
|
|
53
|
+
{ method: "courtsOf", line: 5, calls: ["this.courtModel.countCourt", "this.courtModel.getCourt"] },
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
});
|