@akanjs/devkit 3.0.0-alpha.4 → 3.0.0-alpha.40
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/README.ko.md +1 -1
- package/README.md +1 -1
- package/agentsIndex.test.ts +10 -0
- package/agentsIndex.ts +47 -1
- package/aiEditor.ts +1 -1
- package/akanConfig/akanConfig.test.ts +0 -10
- package/akanConfig/akanConfig.ts +1 -11
- package/akanContext.ts +43 -31
- package/applicationBuildRunner.test.ts +1 -1
- package/applicationBuildRunner.ts +1 -2
- package/biome.base.json +321 -0
- package/biomeBase.ts +9 -0
- package/executors.test.ts +29 -5
- package/executors.ts +11 -4
- package/formSetterScanner.test.ts +80 -0
- package/formSetterScanner.ts +92 -0
- package/frontendBuild/allRoutesBuilder.ts +27 -8
- package/frontendBuild/buildRouteClient.test.ts +28 -2
- package/frontendBuild/clientBuildTypes.ts +4 -0
- package/frontendBuild/clientEntriesBundler.ts +4 -1
- package/frontendBuild/cssCandidateCache.test.ts +11 -0
- package/frontendBuild/cssCandidateCache.ts +9 -2
- package/frontendBuild/cssCompiler.ts +122 -11
- package/frontendBuild/cssImportResolver.ts +8 -7
- package/frontendBuild/frontendBuild.test.ts +88 -1
- package/frontendBuild/precompressArtifacts.ts +35 -7
- package/frontendBuild/routeClientBuilder.ts +26 -7
- package/frontendBuild/ssrBaseArtifactBuilder.ts +20 -4
- package/frontendBuild/vendorSpecifiers.ts +1 -0
- package/getCredentials.ts +1 -3
- package/incrementalBuilder/devWatchBatch.test.ts +18 -20
- package/incrementalBuilder/incrementalBuilder.host.ts +1 -1
- package/incrementalBuilder/incrementalBuilder.proc.ts +2 -2
- package/integration/devResourceProbe.ts +7 -2
- package/integration/devStabilityHarness.ts +1 -5
- package/integration/ssrMemoryProbe.ts +542 -0
- package/lint/no-bang-comment-in-client.grit +20 -0
- package/lint/no-import-client-in-server.grit +48 -0
- package/lint/no-import-server-in-client.grit +45 -0
- package/lint/no-model-type-in-util-zone.grit +58 -0
- package/lint/no-return-in-store-action.grit +35 -0
- package/lint/no-unpublished-form-setter.grit +41 -0
- package/linter.ts +17 -12
- package/package.json +4 -4
- package/qualityScanner.test.ts +163 -1
- package/qualityScanner.ts +51 -46
- package/repoIdentity.ts +42 -0
- package/routeSourceValidator.test.ts +41 -0
- package/routeSourceValidator.ts +2 -44
- package/scanInfo.ts +2 -43
- package/spinner.test.ts +81 -0
- package/spinner.ts +22 -2
- package/ssrScanner.ts +409 -0
- package/transforms/externalizeFrameworkPlugin.ts +0 -1
- package/workspaceLayout.test.ts +26 -0
- package/workspaceLayout.ts +60 -0
package/qualityScanner.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { RESERVED_ROUTE_CONFIG_EXPORTS } from "akanjs/common";
|
|
4
5
|
import ignore from "ignore";
|
|
5
6
|
import ts from "typescript";
|
|
6
7
|
import { AbstractDoc } from "./abstractDoc";
|
|
8
|
+
import { FormSetterScanner } from "./formSetterScanner";
|
|
9
|
+
import { formatSsrBalance, type SsrBalanceEntry, SsrScanner } from "./ssrScanner";
|
|
10
|
+
import { appRootAllowedFiles, libFacetRootAllowedFiles } from "./workspaceLayout";
|
|
7
11
|
|
|
8
12
|
type QualitySeverity = "warning";
|
|
9
|
-
type QualityScope = "global" | "file" | "convention" | "layout";
|
|
13
|
+
type QualityScope = "global" | "file" | "convention" | "layout" | "ssr" | "agent";
|
|
10
14
|
|
|
11
15
|
export interface QualityWarning {
|
|
12
16
|
rule: string;
|
|
@@ -23,10 +27,11 @@ export interface QualityScanResult {
|
|
|
23
27
|
workspaceRoot: string;
|
|
24
28
|
scannedFiles: number;
|
|
25
29
|
warnings: QualityWarning[];
|
|
30
|
+
ssrBalance: SsrBalanceEntry[];
|
|
26
31
|
suggestedRules: string[];
|
|
27
32
|
}
|
|
28
33
|
|
|
29
|
-
interface SourceFileInfo {
|
|
34
|
+
export interface SourceFileInfo {
|
|
30
35
|
file: string;
|
|
31
36
|
absolutePath: string;
|
|
32
37
|
content: string;
|
|
@@ -84,33 +89,11 @@ const SUGGESTED_RULES = [
|
|
|
84
89
|
"Keep apps/*/lib and libs/*/lib root files limited to generated support facets such as cnst.ts, db.ts, dict.ts, sig.ts, srv.ts, st.ts, useClient.ts, and useServer.ts.",
|
|
85
90
|
"Use domain module folders consistently: lib/<model> for database modules, lib/_<service> for service modules, and lib/__scalar/<scalar> for scalar modules.",
|
|
86
91
|
"Keep module UI filenames predictable: database modules use <Model>.Template/Unit/Util/View/Zone.tsx, service modules use <Service>.Util/Zone.tsx, and scalar modules use <Scalar>.Template/Unit.tsx.",
|
|
92
|
+
"Hand a form control its store setter by reference so the field publishes an agent tool; put normalization in the control's transform prop and a multi-write in a _postSet<Field> hook on the store.",
|
|
87
93
|
"Move shared app utilities to apps/*/common instead of creating apps/*/base.",
|
|
88
94
|
"Avoid large mixed-purpose class files; class export files should import helpers from neighboring utility files instead of declaring them inline.",
|
|
89
95
|
];
|
|
90
96
|
|
|
91
|
-
const APP_ROOT_FILES = new Set([
|
|
92
|
-
"akan.app.json",
|
|
93
|
-
"akan.config.ts",
|
|
94
|
-
"capacitor.config.ts",
|
|
95
|
-
"client.ts",
|
|
96
|
-
"main.ts",
|
|
97
|
-
"package.json",
|
|
98
|
-
"server.ts",
|
|
99
|
-
"tsconfig.json",
|
|
100
|
-
]);
|
|
101
|
-
|
|
102
|
-
const LIB_ROOT_FILES = new Set([
|
|
103
|
-
"cnst.ts",
|
|
104
|
-
"db.ts",
|
|
105
|
-
"dict.ts",
|
|
106
|
-
"option.ts",
|
|
107
|
-
"sig.ts",
|
|
108
|
-
"srv.ts",
|
|
109
|
-
"st.ts",
|
|
110
|
-
"useClient.ts",
|
|
111
|
-
"useServer.ts",
|
|
112
|
-
]);
|
|
113
|
-
|
|
114
97
|
const CONVENTION_SUFFIXES = [
|
|
115
98
|
".constant.ts",
|
|
116
99
|
".dictionary.ts",
|
|
@@ -120,24 +103,6 @@ const CONVENTION_SUFFIXES = [
|
|
|
120
103
|
".store.ts",
|
|
121
104
|
] as const;
|
|
122
105
|
|
|
123
|
-
// Non-PascalCase exports the framework recognizes on page/layout route modules (see PageModule/LayoutModule
|
|
124
|
-
// in pkgs/akanjs/client/csrTypes.ts). PascalCase route exports (Loading, NotFound, Error) pass the component
|
|
125
|
-
// check, and the `default` export is handled separately.
|
|
126
|
-
const PAGE_RESERVED_EXPORTS = new Set([
|
|
127
|
-
"pageConfig",
|
|
128
|
-
"head",
|
|
129
|
-
"metadata",
|
|
130
|
-
"generateHead",
|
|
131
|
-
"generateMetadata",
|
|
132
|
-
"fonts",
|
|
133
|
-
"manifest",
|
|
134
|
-
"theme",
|
|
135
|
-
"reconnect",
|
|
136
|
-
"wsConnect",
|
|
137
|
-
"layoutStyle",
|
|
138
|
-
"gaTrackingId",
|
|
139
|
-
]);
|
|
140
|
-
|
|
141
106
|
// How to remediate each rule, keyed by rule id. Surfaced as a `fix:` line per warning (text + JSON output)
|
|
142
107
|
// so the scan result tells the reader what to do, not just what is wrong.
|
|
143
108
|
const RULE_FIXES: Record<string, string> = {
|
|
@@ -159,6 +124,8 @@ const RULE_FIXES: Record<string, string> = {
|
|
|
159
124
|
"Move the Window augmentation into an approved browser integration file and keep it isolated.",
|
|
160
125
|
"akan.file.prototype-mutation": "Avoid prototype mutation, or isolate it in an approved low-level integration file.",
|
|
161
126
|
"akan.file.class-export-global-declaration": "Move the helper to a sibling file and import it into the class module.",
|
|
127
|
+
"akan.agent.unpublished-form-setter":
|
|
128
|
+
"Pass the setter by reference where the wrapper only forwards (onChange={st.do.setTitleOnTask}); move normalization into the control's own transform prop; move a multi-write into a _postSet<Field> hook on the store, keeping the generated setter on the control.",
|
|
162
129
|
"akan.file.component-internal-declaration":
|
|
163
130
|
"Move the type or helper to a type/util file in ui/, webkit/, or common/ by purpose. If it is the component's props, declare it as `interface <Component>Props`.",
|
|
164
131
|
"akan.file.component-export":
|
|
@@ -169,6 +136,18 @@ const RULE_FIXES: Record<string, string> = {
|
|
|
169
136
|
"Move the file into a domain module folder under lib/; keep lib root limited to generated support facets.",
|
|
170
137
|
"akan.layout.module-ui-file":
|
|
171
138
|
"Rename the file to an allowed module UI name, or move it to ui/ if it is not a module component.",
|
|
139
|
+
"akan.ssr.unnecessary-use-client":
|
|
140
|
+
'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.',
|
|
141
|
+
"akan.ssr.client-static-component":
|
|
142
|
+
"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.",
|
|
143
|
+
"akan.ssr.client-static-markup":
|
|
144
|
+
"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.",
|
|
145
|
+
"akan.ssr.client-mount-load":
|
|
146
|
+
"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.",
|
|
147
|
+
"akan.ssr.module-missing-server-view":
|
|
148
|
+
"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.",
|
|
149
|
+
"akan.ssr.template-client-state":
|
|
150
|
+
"Bind the field to the store instead: `value={xForm.field}` with `onChange={st.do.setFieldOnX}`.",
|
|
172
151
|
};
|
|
173
152
|
|
|
174
153
|
function getRuleFix(rule: string): string | undefined {
|
|
@@ -190,6 +169,7 @@ export class AkanQualityScanner {
|
|
|
190
169
|
.filter((file) => AbstractDoc.isAbstractPath(file))
|
|
191
170
|
.map((file) => this.#readTextFile(workspaceRoot, file)),
|
|
192
171
|
);
|
|
172
|
+
const ssr = new SsrScanner().scan(sourceFiles);
|
|
193
173
|
const warnings = [
|
|
194
174
|
...this.#scanGlobalQuality(sourceFiles),
|
|
195
175
|
...sourceFiles.flatMap((sourceFile) => this.#scanSingleFileQuality(sourceFile)),
|
|
@@ -197,6 +177,8 @@ export class AkanQualityScanner {
|
|
|
197
177
|
...sourceFiles.flatMap((sourceFile) => this.#scanConventionQuality(sourceFile)),
|
|
198
178
|
...sourceFiles.flatMap((sourceFile) => this.#scanLayoutQuality(sourceFile)),
|
|
199
179
|
...abstractFiles.flatMap((abstractFile) => this.#scanAbstractQuality(abstractFile)),
|
|
180
|
+
...new FormSetterScanner().scan(sourceFiles),
|
|
181
|
+
...ssr.warnings,
|
|
200
182
|
];
|
|
201
183
|
|
|
202
184
|
return {
|
|
@@ -205,6 +187,7 @@ export class AkanQualityScanner {
|
|
|
205
187
|
warnings: warnings
|
|
206
188
|
.map((warning) => ({ ...warning, fix: warning.fix ?? getRuleFix(warning.rule) }))
|
|
207
189
|
.sort(compareWarnings),
|
|
190
|
+
ssrBalance: ssr.balance,
|
|
208
191
|
suggestedRules: SUGGESTED_RULES,
|
|
209
192
|
};
|
|
210
193
|
}
|
|
@@ -432,7 +415,7 @@ export class AkanQualityScanner {
|
|
|
432
415
|
#scanLayoutQuality(sourceFile: SourceFileInfo): QualityWarning[] {
|
|
433
416
|
const segments = sourceFile.file.split("/");
|
|
434
417
|
const warnings: QualityWarning[] = [];
|
|
435
|
-
if (segments[0] === "apps" && segments.length === 3 && !
|
|
418
|
+
if (segments[0] === "apps" && segments.length === 3 && !appRootAllowedFiles.has(segments[2])) {
|
|
436
419
|
warnings.push({
|
|
437
420
|
rule: "akan.layout.app-root-file",
|
|
438
421
|
scope: "layout",
|
|
@@ -443,7 +426,7 @@ export class AkanQualityScanner {
|
|
|
443
426
|
}
|
|
444
427
|
|
|
445
428
|
const libRootFile = getLibRootFile(sourceFile.file);
|
|
446
|
-
if (libRootFile && !
|
|
429
|
+
if (libRootFile && !libFacetRootAllowedFiles.has(libRootFile)) {
|
|
447
430
|
warnings.push({
|
|
448
431
|
rule: "akan.layout.lib-root-file",
|
|
449
432
|
scope: "layout",
|
|
@@ -470,6 +453,10 @@ export function formatQualityScanResult(result: QualityScanResult) {
|
|
|
470
453
|
"",
|
|
471
454
|
...formatQualityWarnings(result.warnings),
|
|
472
455
|
"",
|
|
456
|
+
"SSR balance (component files, JSX elements rendered per side):",
|
|
457
|
+
"",
|
|
458
|
+
...formatSsrBalance(result.ssrBalance),
|
|
459
|
+
"",
|
|
473
460
|
"Suggested quality rules:",
|
|
474
461
|
"",
|
|
475
462
|
...result.suggestedRules.map((rule) => ` - ${rule}`),
|
|
@@ -477,6 +464,24 @@ export function formatQualityScanResult(result: QualityScanResult) {
|
|
|
477
464
|
return sections.join("\n");
|
|
478
465
|
}
|
|
479
466
|
|
|
467
|
+
export function formatSsrScanResult(result: QualityScanResult) {
|
|
468
|
+
const sections = [
|
|
469
|
+
"Akan SSR Balance Scan",
|
|
470
|
+
`workspace: ${result.workspaceRoot}`,
|
|
471
|
+
`scanned files: ${result.scannedFiles}`,
|
|
472
|
+
`ssr warnings: ${result.warnings.length}`,
|
|
473
|
+
"",
|
|
474
|
+
"Server render share (component files, JSX elements rendered per side):",
|
|
475
|
+
"",
|
|
476
|
+
...formatSsrBalance(result.ssrBalance),
|
|
477
|
+
"",
|
|
478
|
+
"Warnings:",
|
|
479
|
+
"",
|
|
480
|
+
...formatQualityWarnings(result.warnings),
|
|
481
|
+
];
|
|
482
|
+
return sections.join("\n");
|
|
483
|
+
}
|
|
484
|
+
|
|
480
485
|
export function formatQualityWarnings(warnings: QualityWarning[]) {
|
|
481
486
|
if (warnings.length === 0) return ["No warnings found."];
|
|
482
487
|
return warnings.flatMap((warning) => {
|
|
@@ -652,7 +657,7 @@ function isRestrictedInternalKind(kind: ComponentFileDeclaration["kind"]) {
|
|
|
652
657
|
|
|
653
658
|
function isAllowedComponentExport(declaration: ComponentFileDeclaration, isPage: boolean) {
|
|
654
659
|
if (isComponentValueKind(declaration.kind) && isPascalCaseName(declaration.name)) return true;
|
|
655
|
-
return isPage &&
|
|
660
|
+
return isPage && RESERVED_ROUTE_CONFIG_EXPORTS.has(declaration.name);
|
|
656
661
|
}
|
|
657
662
|
|
|
658
663
|
function isPascalCaseName(name: string) {
|
package/repoIdentity.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
// Resolved once per root: every CLI command builds a WorkspaceExecutor, and this would otherwise fork git on each.
|
|
5
|
+
const resolved = new Map<string, string>();
|
|
6
|
+
|
|
7
|
+
const readRemoteName = (workspaceRoot: string): string | null => {
|
|
8
|
+
try {
|
|
9
|
+
const url = execFileSync("git", ["config", "--get", "remote.origin.url"], {
|
|
10
|
+
cwd: workspaceRoot,
|
|
11
|
+
encoding: "utf-8",
|
|
12
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
13
|
+
}).trim();
|
|
14
|
+
// Both remote spellings end in the repository: git@host:owner/name.git and https://host/owner/name.git.
|
|
15
|
+
return (
|
|
16
|
+
url
|
|
17
|
+
.replace(/\.git$/, "")
|
|
18
|
+
.split(/[/:]/)
|
|
19
|
+
.pop() || null
|
|
20
|
+
);
|
|
21
|
+
} catch {
|
|
22
|
+
// No git, no origin, or no git binary — a fresh `akan workspace` before its first commit lands here.
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The repository's own name.
|
|
29
|
+
*
|
|
30
|
+
* Deriving it from the working directory made every generated file that names the repo — the AGENTS.md title, its
|
|
31
|
+
* `- Repo:` line — depend on what each person happened to call the folder they cloned into, so one commit rendered
|
|
32
|
+
* a different guide per developer and the diff never settled. The origin remote is the one identity every clone
|
|
33
|
+
* shares. `AKAN_PUBLIC_REPO_NAME` is deliberately not consulted: that is a deployment namespace (queue prefixes,
|
|
34
|
+
* cache keys, secret paths) which a monorepo hosting several products legitimately points somewhere else.
|
|
35
|
+
*/
|
|
36
|
+
export const resolveRepoName = (workspaceRoot: string): string => {
|
|
37
|
+
const cached = resolved.get(workspaceRoot);
|
|
38
|
+
if (cached) return cached;
|
|
39
|
+
const repoName = readRemoteName(workspaceRoot) ?? path.basename(workspaceRoot);
|
|
40
|
+
resolved.set(workspaceRoot, repoName);
|
|
41
|
+
return repoName;
|
|
42
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { RouteSourceValidator } from "./routeSourceValidator";
|
|
4
|
+
|
|
5
|
+
const validate = (source: string, kind: "page" | "layout", rootLayout = false) =>
|
|
6
|
+
RouteSourceValidator.validateRouteSourceExports(source, `page/_${kind}.tsx`, kind, { rootLayout });
|
|
7
|
+
|
|
8
|
+
describe("RouteSourceValidator", () => {
|
|
9
|
+
test("accepts every root-layout config export the route tree honors", () => {
|
|
10
|
+
const source = [
|
|
11
|
+
"export default function Layout() { return null; }",
|
|
12
|
+
"export const fonts = [];",
|
|
13
|
+
"export const manifest = {};",
|
|
14
|
+
'export const theme = "dark";',
|
|
15
|
+
"export const reconnect = true;",
|
|
16
|
+
"export const wsConnect = true;",
|
|
17
|
+
"export const layoutStyle = {};",
|
|
18
|
+
'export const gaTrackingId = "G-1";',
|
|
19
|
+
].join("\n");
|
|
20
|
+
|
|
21
|
+
expect(() => validate(source, "layout", true)).not.toThrow();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("rejects root-layout-only exports on a nested layout and on a page", () => {
|
|
25
|
+
const source = ["export default function Layout() { return null; }", "export const wsConnect = true;"].join("\n");
|
|
26
|
+
|
|
27
|
+
expect(() => validate(source, "layout")).toThrow('unsupported export "wsConnect"');
|
|
28
|
+
expect(() => validate(source, "page")).toThrow('unsupported export "wsConnect"');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("reads devOnly off pageConfig without evaluating the module", () => {
|
|
32
|
+
const source = [
|
|
33
|
+
"export default function Page() { return null; }",
|
|
34
|
+
"export const pageConfig = { devOnly: true };",
|
|
35
|
+
].join("\n");
|
|
36
|
+
|
|
37
|
+
expect(RouteSourceValidator.validateRouteSourceExports(source, "page/_index.tsx", "page")).toEqual({
|
|
38
|
+
devOnly: true,
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
});
|
package/routeSourceValidator.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getRouteExports } from "akanjs/common";
|
|
1
2
|
import ts from "typescript";
|
|
2
3
|
|
|
3
4
|
/** What the build needs out of a route module without evaluating it. */
|
|
@@ -16,44 +17,6 @@ export interface RouteSourceInfo {
|
|
|
16
17
|
* validate a route source stay lean.
|
|
17
18
|
*/
|
|
18
19
|
export class RouteSourceValidator {
|
|
19
|
-
static readonly #pageExports = new Set([
|
|
20
|
-
"default",
|
|
21
|
-
"pageConfig",
|
|
22
|
-
"head",
|
|
23
|
-
"metadata",
|
|
24
|
-
"generateHead",
|
|
25
|
-
"generateMetadata",
|
|
26
|
-
"Loading",
|
|
27
|
-
]);
|
|
28
|
-
static readonly #rootLayoutExports = new Set([
|
|
29
|
-
"default",
|
|
30
|
-
"pageConfig",
|
|
31
|
-
"head",
|
|
32
|
-
"metadata",
|
|
33
|
-
"generateHead",
|
|
34
|
-
"generateMetadata",
|
|
35
|
-
"fonts",
|
|
36
|
-
"manifest",
|
|
37
|
-
"theme",
|
|
38
|
-
"reconnect",
|
|
39
|
-
"layoutStyle",
|
|
40
|
-
"gaTrackingId",
|
|
41
|
-
"Loading",
|
|
42
|
-
"NotFound",
|
|
43
|
-
"Error",
|
|
44
|
-
]);
|
|
45
|
-
static readonly #layoutExports = new Set([
|
|
46
|
-
"default",
|
|
47
|
-
"pageConfig",
|
|
48
|
-
"head",
|
|
49
|
-
"metadata",
|
|
50
|
-
"generateHead",
|
|
51
|
-
"generateMetadata",
|
|
52
|
-
"Loading",
|
|
53
|
-
"NotFound",
|
|
54
|
-
"Error",
|
|
55
|
-
]);
|
|
56
|
-
|
|
57
20
|
static validateRouteSourceExports(
|
|
58
21
|
source: string,
|
|
59
22
|
filePath: string,
|
|
@@ -61,12 +24,7 @@ export class RouteSourceValidator {
|
|
|
61
24
|
options: { rootLayout?: boolean } = {},
|
|
62
25
|
): RouteSourceInfo {
|
|
63
26
|
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
64
|
-
const allowed =
|
|
65
|
-
kind === "page"
|
|
66
|
-
? RouteSourceValidator.#pageExports
|
|
67
|
-
: options.rootLayout
|
|
68
|
-
? RouteSourceValidator.#rootLayoutExports
|
|
69
|
-
: RouteSourceValidator.#layoutExports;
|
|
27
|
+
const allowed = getRouteExports(kind, { rootLayout: options.rootLayout });
|
|
70
28
|
const exported = new Set<string>();
|
|
71
29
|
const assertExport = (name: string) => {
|
|
72
30
|
if (!allowed.has(name)) {
|
package/scanInfo.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
} from "./akanConfig";
|
|
12
12
|
|
|
13
13
|
import { AppExecutor, LibExecutor, PkgExecutor, WorkspaceExecutor } from "./executors";
|
|
14
|
+
import { appRootAllowedDirs, appRootAllowedFiles, libFacetRootAllowedFiles } from "./workspaceLayout";
|
|
14
15
|
|
|
15
16
|
const scalarFileTypes = ["constant", "dictionary", "document", "template", "unit", "util", "view", "zone"] as const;
|
|
16
17
|
type ScalarFileType = (typeof scalarFileTypes)[number];
|
|
@@ -43,49 +44,7 @@ type DatabaseFileType = (typeof databaseFileTypes)[number];
|
|
|
43
44
|
|
|
44
45
|
type ModuleKind = "database" | "service" | "scalar";
|
|
45
46
|
|
|
46
|
-
const appRootAllowedFiles = new Set([
|
|
47
|
-
// 스코프 에이전트 가이드 — scan(write) 이 유지하는 색인 + 마커 밖 hand-written 내용 (agentsIndex.ts)
|
|
48
|
-
"AGENTS.md",
|
|
49
|
-
"CLAUDE.md",
|
|
50
|
-
"akan.app.json",
|
|
51
|
-
"akan.config.ts",
|
|
52
|
-
"capacitor.config.ts",
|
|
53
|
-
"client.ts",
|
|
54
|
-
"main.ts",
|
|
55
|
-
"package.json",
|
|
56
|
-
"server.ts",
|
|
57
|
-
"tsconfig.json",
|
|
58
|
-
"tsconfig.tsbuildinfo",
|
|
59
|
-
]);
|
|
60
47
|
const generatedRootCapacitorConfigFiles = ["capacitor.config.js", "capacitor.config.json"] as const;
|
|
61
|
-
const appRootAllowedDirs = new Set([
|
|
62
|
-
".akan",
|
|
63
|
-
"android",
|
|
64
|
-
"env",
|
|
65
|
-
"ios",
|
|
66
|
-
"lib",
|
|
67
|
-
"mobile",
|
|
68
|
-
"page",
|
|
69
|
-
"private",
|
|
70
|
-
"public",
|
|
71
|
-
"script",
|
|
72
|
-
"ui",
|
|
73
|
-
"srvkit",
|
|
74
|
-
"webkit",
|
|
75
|
-
"common",
|
|
76
|
-
"secrets",
|
|
77
|
-
]);
|
|
78
|
-
const libRootAllowedFiles = new Set([
|
|
79
|
-
"cnst.ts",
|
|
80
|
-
"db.ts",
|
|
81
|
-
"dict.ts",
|
|
82
|
-
"option.ts",
|
|
83
|
-
"sig.ts",
|
|
84
|
-
"srv.ts",
|
|
85
|
-
"st.ts",
|
|
86
|
-
"useClient.ts",
|
|
87
|
-
"useServer.ts",
|
|
88
|
-
]);
|
|
89
48
|
const internalLibDirs = new Set(["__lib", "__scalar"]);
|
|
90
49
|
const moduleNonUiFileTypes = {
|
|
91
50
|
database: new Set(["constant", "dictionary", "document", "service", "signal", "store"]),
|
|
@@ -107,7 +66,7 @@ const createDependencyScanner = async (exec: AppExecutor | LibExecutor | PkgExec
|
|
|
107
66
|
|
|
108
67
|
const isAllowedTestFile = (filename: string) => testFilePattern.test(filename);
|
|
109
68
|
const isAllowedLibRootFile = (filename: string) =>
|
|
110
|
-
|
|
69
|
+
libFacetRootAllowedFiles.has(filename) || rootSignalTestFilePattern.test(filename);
|
|
111
70
|
const getScanPath = (exec: AppExecutor | LibExecutor, relativePath: string) =>
|
|
112
71
|
path.posix.join(`${exec.type}s`, exec.name, relativePath.split(path.sep).join("/"));
|
|
113
72
|
async function clearGeneratedRootCapacitorConfigs(exec: AppExecutor | LibExecutor) {
|
package/spinner.test.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Spinner } from "./spinner";
|
|
3
|
+
|
|
4
|
+
interface TtyStub {
|
|
5
|
+
restore: () => void;
|
|
6
|
+
rawModeCalls: boolean[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* ora only reaches for stdin when both streams look interactive, so the stub has to fake a tty on
|
|
11
|
+
* stderr (which decides `isEnabled`) as well as on stdin.
|
|
12
|
+
*/
|
|
13
|
+
const stubTty = (): TtyStub => {
|
|
14
|
+
const rawModeCalls: boolean[] = [];
|
|
15
|
+
const stdin = process.stdin as NodeJS.ReadStream & { setRawMode?: (mode: boolean) => unknown };
|
|
16
|
+
const previous = {
|
|
17
|
+
stdinIsTTY: stdin.isTTY,
|
|
18
|
+
stderrIsTTY: process.stderr.isTTY,
|
|
19
|
+
stderrColumns: process.stderr.columns,
|
|
20
|
+
setRawMode: stdin.setRawMode,
|
|
21
|
+
isPaused: stdin.isPaused,
|
|
22
|
+
};
|
|
23
|
+
stdin.isTTY = true;
|
|
24
|
+
process.stderr.isTTY = true;
|
|
25
|
+
process.stderr.columns = 120;
|
|
26
|
+
stdin.setRawMode = (mode: boolean) => {
|
|
27
|
+
rawModeCalls.push(mode);
|
|
28
|
+
return stdin;
|
|
29
|
+
};
|
|
30
|
+
stdin.isPaused = () => true;
|
|
31
|
+
return {
|
|
32
|
+
rawModeCalls,
|
|
33
|
+
restore: () => {
|
|
34
|
+
stdin.isTTY = previous.stdinIsTTY;
|
|
35
|
+
process.stderr.isTTY = previous.stderrIsTTY;
|
|
36
|
+
process.stderr.columns = previous.stderrColumns;
|
|
37
|
+
stdin.setRawMode = previous.setRawMode;
|
|
38
|
+
stdin.isPaused = previous.isPaused;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
describe("Spinner", () => {
|
|
44
|
+
// A raw terminal at spawn time is what a Bun child snapshots and writes back when it exits, which is
|
|
45
|
+
// how `akan start` used to leave a terminal that no longer turns Ctrl+C into SIGINT.
|
|
46
|
+
test("never puts the terminal into raw mode while it spins", () => {
|
|
47
|
+
const tty = stubTty();
|
|
48
|
+
try {
|
|
49
|
+
const spinner = new Spinner("Preparing backend...").start();
|
|
50
|
+
expect(tty.rawModeCalls).toEqual([]);
|
|
51
|
+
spinner.succeed("prepared");
|
|
52
|
+
expect(tty.rawModeCalls).toEqual([]);
|
|
53
|
+
} finally {
|
|
54
|
+
tty.restore();
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("keeps ora's stdin discarder disabled", () => {
|
|
59
|
+
expect(Spinner.oraOptions.discardStdin).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// An unsized pty reports `isTTY: true` with `columns: 0`, which turns ora's clear loop into an
|
|
63
|
+
// infinite one — the process then writes cursor moves until it is SIGKILLed.
|
|
64
|
+
test("refuses to animate against a tty that reports no width", () => {
|
|
65
|
+
expect(Spinner.canAnimate({ isTTY: true, columns: 0 } as NodeJS.WriteStream)).toBe(false);
|
|
66
|
+
expect(Spinner.canAnimate({ isTTY: true, columns: 120 } as NodeJS.WriteStream)).toBe(true);
|
|
67
|
+
expect(Spinner.canAnimate({ isTTY: false, columns: 0 } as NodeJS.WriteStream)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("falls back to plain lines when the terminal has no width", () => {
|
|
71
|
+
const previous = { isTTY: process.stderr.isTTY, columns: process.stderr.columns };
|
|
72
|
+
process.stderr.isTTY = true;
|
|
73
|
+
process.stderr.columns = 0;
|
|
74
|
+
try {
|
|
75
|
+
expect(new Spinner("Preparing backend...").enableSpin).toBe(false);
|
|
76
|
+
} finally {
|
|
77
|
+
process.stderr.isTTY = previous.isTTY;
|
|
78
|
+
process.stderr.columns = previous.columns;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|
package/spinner.ts
CHANGED
|
@@ -2,6 +2,26 @@ import ora, { type Ora } from "ora";
|
|
|
2
2
|
|
|
3
3
|
export class Spinner {
|
|
4
4
|
static padding = 12;
|
|
5
|
+
/**
|
|
6
|
+
* XXX: `discardStdin` must stay off. It makes ora put the terminal into raw mode for as long as the
|
|
7
|
+
* spinner runs, and a Bun child spawned in that window snapshots the raw termios and writes it back
|
|
8
|
+
* when it exits — long after the spinner restored the terminal. `akan start` spawns the builder and
|
|
9
|
+
* the backend under the "Preparing backend..." spinner, so the first builder recycle (a config or
|
|
10
|
+
* runtime-metadata change) SIGTERMs that child and silently turns the developer's terminal raw:
|
|
11
|
+
* `isig` goes off, Ctrl+C stops producing SIGINT at all, and the dev server looks unkillable.
|
|
12
|
+
*/
|
|
13
|
+
static oraOptions = { discardStdin: false } as const;
|
|
14
|
+
/**
|
|
15
|
+
* ora sizes its clear loop as `ceil(lineWidth / stream.columns)`, so a tty that reports **0** columns
|
|
16
|
+
* makes it `Infinity` and `clear()` never returns. An unsized pty does exactly that (`isTTY: true`,
|
|
17
|
+
* `columns: 0`) — CI runners, `expect`/`script` harnesses, some detached panes. Measured: 750MB of
|
|
18
|
+
* cursor moves and 8.7GB RSS inside a minute, in a loop that no longer reaches the point where SIGINT
|
|
19
|
+
* or SIGTERM could be handled, so only SIGKILL ends it. A terminal with no width has nothing to
|
|
20
|
+
* animate anyway; fall back to plain lines.
|
|
21
|
+
*/
|
|
22
|
+
static canAnimate(stream: NodeJS.WriteStream = process.stderr): boolean {
|
|
23
|
+
return !stream.isTTY || stream.columns > 0;
|
|
24
|
+
}
|
|
5
25
|
spinner: Ora;
|
|
6
26
|
stopWatch: NodeJS.Timeout | null = null;
|
|
7
27
|
startAt: Date = new Date();
|
|
@@ -12,10 +32,10 @@ export class Spinner {
|
|
|
12
32
|
Spinner.padding = Math.max(Spinner.padding, prefix.length);
|
|
13
33
|
this.prefix = prefix;
|
|
14
34
|
this.message = message;
|
|
15
|
-
this.spinner = ora(message);
|
|
35
|
+
this.spinner = ora({ ...Spinner.oraOptions, text: message });
|
|
16
36
|
this.spinner.prefixText = prefix.padStart(Spinner.padding, " ");
|
|
17
37
|
this.spinner.indent = indent;
|
|
18
|
-
this.enableSpin = enableSpin;
|
|
38
|
+
this.enableSpin = enableSpin && Spinner.canAnimate();
|
|
19
39
|
}
|
|
20
40
|
start() {
|
|
21
41
|
this.startAt = new Date();
|