@akanjs/devkit 3.0.0-alpha.5 → 3.0.0-alpha.50
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 +47 -10
- package/akanConfig/akanConfig.ts +23 -16
- package/akanContext.ts +53 -11
- package/applicationBuildRunner.test.ts +1 -1
- package/applicationBuildRunner.ts +0 -1
- package/biome.base.json +321 -0
- package/biomeBase.ts +9 -0
- package/capacitorApp.test.ts +8 -0
- package/capacitorApp.ts +112 -18
- package/executors.test.ts +29 -5
- package/executors.ts +23 -5
- package/formSetterScanner.test.ts +80 -0
- package/formSetterScanner.ts +92 -0
- package/frontendBuild/buildRouteClient.test.ts +28 -2
- package/frontendBuild/clientBuildTypes.ts +4 -0
- package/frontendBuild/clientEntriesBundler.ts +4 -1
- package/frontendBuild/cssCandidateCache.test.ts +38 -0
- package/frontendBuild/cssCandidateCache.ts +15 -3
- package/frontendBuild/cssCompiler.ts +122 -11
- package/frontendBuild/cssImportResolver.ts +8 -7
- package/frontendBuild/frontendBuild.test.ts +88 -1
- package/frontendBuild/hmrWatcher.ts +1 -1
- package/frontendBuild/routeClientBuilder.ts +12 -5
- package/frontendBuild/ssrBaseArtifactBuilder.ts +20 -4
- package/frontendBuild/styleGuard.test.ts +15 -0
- package/frontendBuild/styleGuard.ts +17 -0
- 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/devStabilityHarness.ts +1 -5
- package/lint/no-daisyui-legacy-class.grit +26 -9
- 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 +52 -0
- package/qualityScanner.ts +48 -37
- package/repoIdentity.ts +42 -0
- package/routeSourceValidator.test.ts +41 -0
- package/routeSourceValidator.ts +2 -44
- package/scanInfo.ts +21 -20
- package/transforms/externalizeFrameworkPlugin.ts +0 -1
- package/workspaceLayout.test.ts +56 -4
- package/workspaceLayout.ts +49 -4
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// Every prototype method of a `store(...)` class is registered as an action
|
|
5
|
+
// (StoreRegistry.register) and reaches callers only as `st.do.<action>`, which re-wraps it
|
|
6
|
+
// (StoreInstance.#extendAccessors) and is typed void by `VoidActions` in
|
|
7
|
+
// pkgs/akanjs/store/types.ts. A returned value is therefore unreachable from every call
|
|
8
|
+
// site — it reads like a contract while being dead code. Write the result into state with
|
|
9
|
+
// `this.set({ ... })` instead.
|
|
10
|
+
//
|
|
11
|
+
// Deliberately not flagged, because none of these are actions: a bare `return;` guard, a
|
|
12
|
+
// `return` belonging to a nested callback, a getter, a `static` helper, and a class-property
|
|
13
|
+
// arrow (an instance field, so `register` never sees it).
|
|
14
|
+
`return $value;` as $return where {
|
|
15
|
+
not $value <: r"",
|
|
16
|
+
$return <: within JsClassDeclaration() as $storeClass where {
|
|
17
|
+
$storeClass <: contains JsExtendsClause() as $extends where {
|
|
18
|
+
$extends <: r"extends\s+store\([\s\S]*"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
$return <: within JsMethodClassMember() as $action where {
|
|
22
|
+
not $action <: r"static[\s\S]*"
|
|
23
|
+
},
|
|
24
|
+
not $return <: within JsArrowFunctionExpression(),
|
|
25
|
+
not $return <: within JsFunctionExpression(),
|
|
26
|
+
not $return <: within JsFunctionDeclaration(),
|
|
27
|
+
not $return <: within JsMethodObjectMember(),
|
|
28
|
+
not $return <: within JsGetterObjectMember(),
|
|
29
|
+
not $return <: within JsSetterObjectMember(),
|
|
30
|
+
register_diagnostic(
|
|
31
|
+
span = $return,
|
|
32
|
+
message = "Store actions dispatch as void — st.do.<action>() never exposes this value. Write the result into state with this.set({ ... }) and drop the returned value; a bare 'return;' guard clause is fine.",
|
|
33
|
+
severity = "error"
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// A form control publishes its own setter by reading the action off the function it was handed
|
|
5
|
+
// (`actionTagOf` in pkgs/akanjs/store/actionTag.ts). A setter passed by reference names the field it
|
|
6
|
+
// writes, so the control emits `data-akan-action` and `useFieldTool` publishes the field to the in-page
|
|
7
|
+
// agent. An arrow that only forwards its argument is a fresh anonymous closure carrying neither, so the
|
|
8
|
+
// field silently becomes unreachable to the agent, to an E2E selector, and to the accessibility tree —
|
|
9
|
+
// while looking identical to a reader. Generated field setters take exactly one value
|
|
10
|
+
// (`makeFormSetter` in pkgs/akanjs/store/action.ts), so the wrapper never changes what runs.
|
|
11
|
+
//
|
|
12
|
+
// Only a pure forwarding body is reported: the argument reaches the setter unchanged, as the sole
|
|
13
|
+
// statement. Each other shape has its own home, so none of them is reported here:
|
|
14
|
+
//
|
|
15
|
+
// normalize `(v) => set(formatPhone(v))` -> the control's own `transform` prop, which every text and
|
|
16
|
+
// number `Field.*` already takes. Keep `onChange` a reference.
|
|
17
|
+
// composite `(v) => { set(v); other(v); }` -> a `_postSet<Field>` method on the store, with the generated
|
|
18
|
+
// setter left on the control. A generated action cannot be
|
|
19
|
+
// overridden: mapped types make them properties (TS2425).
|
|
20
|
+
// nested `(v) => writeOnX('a.3.b', v)` -> unannotatable by design. An agent reaches an embedded row
|
|
21
|
+
// through `fill<Model>Form`, which waves composites through.
|
|
22
|
+
//
|
|
23
|
+
// A typed parameter (`(v: string) => st.do.setVOnX(v)`) is not matched: the parameter and the argument
|
|
24
|
+
// bind to different text, so the metavariable cannot unify them. Under-reporting, not a false positive.
|
|
25
|
+
//
|
|
26
|
+
JsxAttribute() as $attr where {
|
|
27
|
+
$attr <: contains JsArrowFunctionExpression() as $arrow,
|
|
28
|
+
$arrow <: `($p) => $body`,
|
|
29
|
+
// One bare parameter. A metavariable matches a whole parameter list leniently, so `(a, b)` binds the
|
|
30
|
+
// text "a, b" and is excluded here rather than by the snippet.
|
|
31
|
+
$p <: r"[A-Za-z_$][A-Za-z0-9_$]*",
|
|
32
|
+
$body <: contains `st.do.$action($p)` as $call,
|
|
33
|
+
$action <: r"set[A-Za-z0-9_$]*On[A-Za-z0-9_$]*",
|
|
34
|
+
// The body is that call and nothing else — `r"..."` is a full match, so an extra statement fails it.
|
|
35
|
+
$body <: r"\{?\s*(?:void\s+|await\s+)?st\.do\.[A-Za-z0-9_$]+\([A-Za-z_$][A-Za-z0-9_$]*\)\s*;?\s*\}?",
|
|
36
|
+
register_diagnostic(
|
|
37
|
+
span = $arrow,
|
|
38
|
+
message = "This arrow only forwards its argument, and an anonymous closure names no action — the control publishes no agent tool and emits no data-akan-action for the field. Pass the setter by reference: onChange={st.do.setTypeOnTicket}. A wrapper that does more is not reported: normalization belongs in the control's transform prop, a multi-write belongs in a _postSet<Field> hook on the store, and a nested path through writeOnX is reached by the agent through fill<Model>Form.",
|
|
39
|
+
severity = "error"
|
|
40
|
+
)
|
|
41
|
+
}
|
package/linter.ts
CHANGED
|
@@ -58,19 +58,31 @@ interface LintResponse {
|
|
|
58
58
|
warnings: LintMessage[];
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/** Biome reads `biome.json` first and `biome.jsonc` second; only the latter may carry comments. */
|
|
62
|
+
const BIOME_CONFIG_FILES = ["biome.json", "biome.jsonc"] as const;
|
|
63
|
+
|
|
61
64
|
export class Linter {
|
|
62
65
|
lintRoot: string;
|
|
66
|
+
configPath: string;
|
|
63
67
|
#biomeBin: string;
|
|
64
68
|
|
|
65
69
|
constructor(cwdPath: string) {
|
|
66
70
|
this.lintRoot = this.#findBiomeRootPath(cwdPath);
|
|
71
|
+
this.configPath = Linter.#configPathIn(this.lintRoot) ?? path.join(this.lintRoot, "biome.json");
|
|
67
72
|
const localBiomeBin = path.join(this.lintRoot, "node_modules/.bin/biome");
|
|
68
73
|
this.#biomeBin = existsSync(localBiomeBin) ? localBiomeBin : "biome";
|
|
69
74
|
}
|
|
70
75
|
|
|
76
|
+
static #configPathIn(dir: string): string | null {
|
|
77
|
+
for (const fileName of BIOME_CONFIG_FILES) {
|
|
78
|
+
const configPath = path.join(dir, fileName);
|
|
79
|
+
if (existsSync(configPath)) return configPath;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
71
84
|
#findBiomeRootPath(dir: string): string {
|
|
72
|
-
|
|
73
|
-
if (existsSync(configPath)) return dir;
|
|
85
|
+
if (Linter.#configPathIn(dir)) return dir;
|
|
74
86
|
const parentDir = path.dirname(dir);
|
|
75
87
|
if (parentDir === dir) throw new Error(`biome.json not found from ${dir}`);
|
|
76
88
|
return this.#findBiomeRootPath(parentDir);
|
|
@@ -193,7 +205,7 @@ export class Linter {
|
|
|
193
205
|
"--max-diagnostics=none",
|
|
194
206
|
"--no-errors-on-unmatched",
|
|
195
207
|
"--config-path",
|
|
196
|
-
|
|
208
|
+
this.configPath,
|
|
197
209
|
this.#toBiomePath(filePath),
|
|
198
210
|
]);
|
|
199
211
|
const report = this.#parseBiomeReport(stdout || stderr);
|
|
@@ -390,14 +402,7 @@ export class Linter {
|
|
|
390
402
|
|
|
391
403
|
const source = readFileSync(resolvedFilePath, "utf8");
|
|
392
404
|
const { stdout } = await this.#runBiome(
|
|
393
|
-
[
|
|
394
|
-
"check",
|
|
395
|
-
"--write",
|
|
396
|
-
"--config-path",
|
|
397
|
-
path.join(this.lintRoot, "biome.json"),
|
|
398
|
-
"--stdin-file-path",
|
|
399
|
-
this.#toBiomePath(resolvedFilePath),
|
|
400
|
-
],
|
|
405
|
+
["check", "--write", "--config-path", this.configPath, "--stdin-file-path", this.#toBiomePath(resolvedFilePath)],
|
|
401
406
|
source,
|
|
402
407
|
);
|
|
403
408
|
const lintResult = await this.lintFile(resolvedFilePath);
|
|
@@ -412,7 +417,7 @@ export class Linter {
|
|
|
412
417
|
async getConfigForFile(filePath: string): Promise<unknown> {
|
|
413
418
|
const resolvedFilePath = this.#resolveFilePath(filePath);
|
|
414
419
|
if (!existsSync(resolvedFilePath)) throw new Error(`File not found: ${filePath}`);
|
|
415
|
-
return JSON.parse(readFileSync(
|
|
420
|
+
return JSON.parse(readFileSync(this.configPath, "utf8")) as unknown;
|
|
416
421
|
}
|
|
417
422
|
|
|
418
423
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.50",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"directory": "pkgs/@akanjs/devkit"
|
|
15
15
|
},
|
|
16
16
|
"engines": {
|
|
17
|
-
"bun": ">=1.
|
|
17
|
+
"bun": ">=1.4.0"
|
|
18
18
|
},
|
|
19
19
|
"exports": {
|
|
20
20
|
".": {
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"default": "./index.ts"
|
|
24
24
|
},
|
|
25
25
|
"./package.json": "./package.json",
|
|
26
|
+
"./biome.base.json": "./biome.base.json",
|
|
26
27
|
"./akanApp": "./akanApp/index.ts",
|
|
27
28
|
"./akanConfig": "./akanConfig/index.ts",
|
|
28
29
|
"./artifact": "./artifact/index.ts",
|
|
@@ -44,7 +45,7 @@
|
|
|
44
45
|
"@langchain/openai": "^1.4.6",
|
|
45
46
|
"@tailwindcss/node": "^4.3.0",
|
|
46
47
|
"@trapezedev/project": "^7.1.4",
|
|
47
|
-
"akanjs": "3.0.0-alpha.
|
|
48
|
+
"akanjs": "3.0.0-alpha.50",
|
|
48
49
|
"chalk": "^5.6.2",
|
|
49
50
|
"commander": "^14.0.3",
|
|
50
51
|
"dayjs": "^1.11.20",
|
|
@@ -52,7 +53,6 @@
|
|
|
52
53
|
"fonteditor-core": "^2.6.3",
|
|
53
54
|
"ignore": "^7.0.5",
|
|
54
55
|
"ink": "^6.8.0",
|
|
55
|
-
"js-yaml": "^4.1.1",
|
|
56
56
|
"ora": "^9.4.0",
|
|
57
57
|
"ssh2": "^1.17.0",
|
|
58
58
|
"subset-font": "^2.5.0",
|
package/qualityScanner.test.ts
CHANGED
|
@@ -184,6 +184,15 @@ describe("AkanQualityScanner ssr rules", () => {
|
|
|
184
184
|
});
|
|
185
185
|
});
|
|
186
186
|
|
|
187
|
+
const signalOf = (entries: string) =>
|
|
188
|
+
[
|
|
189
|
+
`import { endpoint, slice } from "akanjs/signal";`,
|
|
190
|
+
`export class PostSlice extends slice(srv.post, { guards: {}, mcp: { get: true } }, (init) => ({`,
|
|
191
|
+
entries,
|
|
192
|
+
`})) {}`,
|
|
193
|
+
"",
|
|
194
|
+
].join("\n");
|
|
195
|
+
|
|
187
196
|
describe("AkanQualityScanner layout rules", () => {
|
|
188
197
|
test("flags an unknown app root file but not a facet entrypoint", async () => {
|
|
189
198
|
const root = await makeWorkspace({
|
|
@@ -196,4 +205,47 @@ describe("AkanQualityScanner layout rules", () => {
|
|
|
196
205
|
expect(warnings).toHaveLength(1);
|
|
197
206
|
expect(warnings[0]?.file).toBe("apps/demo/helper.ts");
|
|
198
207
|
});
|
|
208
|
+
|
|
209
|
+
test("flags an unknown lib root file but not a facet entrypoint", async () => {
|
|
210
|
+
const root = await makeWorkspace({
|
|
211
|
+
"libs/demo/client.ts": "export const client = 1;\n",
|
|
212
|
+
"libs/demo/helper.ts": "export const helper = 1;\n",
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.layout.lib-root-file");
|
|
216
|
+
|
|
217
|
+
expect(warnings).toHaveLength(1);
|
|
218
|
+
expect(warnings[0]?.file).toBe("libs/demo/helper.ts");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("flags a root folder no facet owns, on both sides", async () => {
|
|
222
|
+
const root = await makeWorkspace({
|
|
223
|
+
"apps/demo/base/helper.ts": "export const helper = 1;\n",
|
|
224
|
+
"apps/demo/common/helper.ts": "export const helper = 1;\n",
|
|
225
|
+
"libs/demo/base/helper.ts": "export const helper = 1;\n",
|
|
226
|
+
"libs/demo/common/helper.ts": "export const helper = 1;\n",
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const result = await new AkanQualityScanner().scan(root);
|
|
230
|
+
|
|
231
|
+
expect(rulesOf(result, "akan.layout.app-root-folder").map((warning) => warning.file)).toEqual([
|
|
232
|
+
"apps/demo/base/helper.ts",
|
|
233
|
+
]);
|
|
234
|
+
expect(rulesOf(result, "akan.layout.lib-root-folder").map((warning) => warning.file)).toEqual([
|
|
235
|
+
"libs/demo/base/helper.ts",
|
|
236
|
+
]);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("keeps a root signal test out of the lib facet rule", async () => {
|
|
240
|
+
const root = await makeWorkspace({
|
|
241
|
+
"libs/demo/lib/cnst.ts": "export const cnst = 1;\n",
|
|
242
|
+
"libs/demo/lib/user.signal.test.ts": "export const test = 1;\n",
|
|
243
|
+
"libs/demo/lib/helper.ts": "export const helper = 1;\n",
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.layout.lib-facet-file");
|
|
247
|
+
|
|
248
|
+
expect(warnings).toHaveLength(1);
|
|
249
|
+
expect(warnings[0]?.file).toBe("libs/demo/lib/helper.ts");
|
|
250
|
+
});
|
|
199
251
|
});
|
package/qualityScanner.ts
CHANGED
|
@@ -1,14 +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";
|
|
7
9
|
import { formatSsrBalance, type SsrBalanceEntry, SsrScanner } from "./ssrScanner";
|
|
8
|
-
import {
|
|
10
|
+
import { isAllowedLibFacetRootFile, rootAllowedDirs, rootAllowedFiles } from "./workspaceLayout";
|
|
9
11
|
|
|
10
12
|
type QualitySeverity = "warning";
|
|
11
|
-
type QualityScope = "global" | "file" | "convention" | "layout" | "ssr";
|
|
13
|
+
type QualityScope = "global" | "file" | "convention" | "layout" | "ssr" | "agent";
|
|
12
14
|
|
|
13
15
|
export interface QualityWarning {
|
|
14
16
|
rule: string;
|
|
@@ -87,7 +89,8 @@ const SUGGESTED_RULES = [
|
|
|
87
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.",
|
|
88
90
|
"Use domain module folders consistently: lib/<model> for database modules, lib/_<service> for service modules, and lib/__scalar/<scalar> for scalar modules.",
|
|
89
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.",
|
|
90
|
-
"
|
|
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.",
|
|
93
|
+
"Move shared app and lib utilities to common/ instead of creating apps/*/base or libs/*/base.",
|
|
91
94
|
"Avoid large mixed-purpose class files; class export files should import helpers from neighboring utility files instead of declaring them inline.",
|
|
92
95
|
];
|
|
93
96
|
|
|
@@ -100,24 +103,6 @@ const CONVENTION_SUFFIXES = [
|
|
|
100
103
|
".store.ts",
|
|
101
104
|
] as const;
|
|
102
105
|
|
|
103
|
-
// Non-PascalCase exports the framework recognizes on page/layout route modules (see PageModule/LayoutModule
|
|
104
|
-
// in pkgs/akanjs/client/csrTypes.ts). PascalCase route exports (Loading, NotFound, Error) pass the component
|
|
105
|
-
// check, and the `default` export is handled separately.
|
|
106
|
-
const PAGE_RESERVED_EXPORTS = new Set([
|
|
107
|
-
"pageConfig",
|
|
108
|
-
"head",
|
|
109
|
-
"metadata",
|
|
110
|
-
"generateHead",
|
|
111
|
-
"generateMetadata",
|
|
112
|
-
"fonts",
|
|
113
|
-
"manifest",
|
|
114
|
-
"theme",
|
|
115
|
-
"reconnect",
|
|
116
|
-
"wsConnect",
|
|
117
|
-
"layoutStyle",
|
|
118
|
-
"gaTrackingId",
|
|
119
|
-
]);
|
|
120
|
-
|
|
121
106
|
// How to remediate each rule, keyed by rule id. Surfaced as a `fix:` line per warning (text + JSON output)
|
|
122
107
|
// so the scan result tells the reader what to do, not just what is wrong.
|
|
123
108
|
const RULE_FIXES: Record<string, string> = {
|
|
@@ -139,14 +124,22 @@ const RULE_FIXES: Record<string, string> = {
|
|
|
139
124
|
"Move the Window augmentation into an approved browser integration file and keep it isolated.",
|
|
140
125
|
"akan.file.prototype-mutation": "Avoid prototype mutation, or isolate it in an approved low-level integration file.",
|
|
141
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.",
|
|
142
129
|
"akan.file.component-internal-declaration":
|
|
143
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`.",
|
|
144
131
|
"akan.file.component-export":
|
|
145
132
|
"Move the value or type to a util/constant/type file in ui/, webkit/, or common/ and import it. Adding `export` is not a valid fix — only PascalCase components and their `<Component>Props` interface belong here.",
|
|
146
133
|
"akan.layout.app-root-file":
|
|
147
134
|
"Move the file into a conventional app folder (common, env, lib, page, private, public, script, srvkit, ui, or webkit).",
|
|
135
|
+
"akan.layout.app-root-folder":
|
|
136
|
+
"Move the folder's contents into a conventional app folder (common, env, lib, page, private, public, script, srvkit, ui, or webkit) and delete it.",
|
|
148
137
|
"akan.layout.lib-root-file":
|
|
149
|
-
"Move the file into a
|
|
138
|
+
"Move the file into a conventional lib folder (common, env, lib, page, private, public, srvkit, ui, or webkit).",
|
|
139
|
+
"akan.layout.lib-root-folder":
|
|
140
|
+
"Move the folder's contents into a conventional lib folder (common, env, lib, page, private, public, srvkit, ui, or webkit) and delete it.",
|
|
141
|
+
"akan.layout.lib-facet-file":
|
|
142
|
+
"Move the file into a domain module folder under lib/; keep the lib facet root limited to generated support facets.",
|
|
150
143
|
"akan.layout.module-ui-file":
|
|
151
144
|
"Rename the file to an allowed module UI name, or move it to ui/ if it is not a module component.",
|
|
152
145
|
"akan.ssr.unnecessary-use-client":
|
|
@@ -190,6 +183,7 @@ export class AkanQualityScanner {
|
|
|
190
183
|
...sourceFiles.flatMap((sourceFile) => this.#scanConventionQuality(sourceFile)),
|
|
191
184
|
...sourceFiles.flatMap((sourceFile) => this.#scanLayoutQuality(sourceFile)),
|
|
192
185
|
...abstractFiles.flatMap((abstractFile) => this.#scanAbstractQuality(abstractFile)),
|
|
186
|
+
...new FormSetterScanner().scan(sourceFiles),
|
|
193
187
|
...ssr.warnings,
|
|
194
188
|
];
|
|
195
189
|
|
|
@@ -425,26 +419,31 @@ export class AkanQualityScanner {
|
|
|
425
419
|
}
|
|
426
420
|
|
|
427
421
|
#scanLayoutQuality(sourceFile: SourceFileInfo): QualityWarning[] {
|
|
428
|
-
const segments = sourceFile.file.split("/");
|
|
429
422
|
const warnings: QualityWarning[] = [];
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
423
|
+
const rootEntry = getSysRootEntry(sourceFile.file);
|
|
424
|
+
if (rootEntry) {
|
|
425
|
+
const { type, name, isDir } = rootEntry;
|
|
426
|
+
const allowed = isDir ? rootAllowedDirs[type].has(name) : rootAllowedFiles[type].has(name);
|
|
427
|
+
const kind = isDir ? "folder" : "file";
|
|
428
|
+
if (!allowed) {
|
|
429
|
+
warnings.push({
|
|
430
|
+
rule: `akan.layout.${type}-root-${kind}`,
|
|
431
|
+
scope: "layout",
|
|
432
|
+
severity: "warning",
|
|
433
|
+
file: sourceFile.file,
|
|
434
|
+
message: `Unexpected ${type} root ${kind} "${name}". Keep ${type} code in conventional ${type} folders.`,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
438
437
|
}
|
|
439
438
|
|
|
440
|
-
const
|
|
441
|
-
if (
|
|
439
|
+
const libFacetFile = getLibFacetRootFile(sourceFile.file);
|
|
440
|
+
if (libFacetFile && !isAllowedLibFacetRootFile(libFacetFile)) {
|
|
442
441
|
warnings.push({
|
|
443
|
-
rule: "akan.layout.lib-
|
|
442
|
+
rule: "akan.layout.lib-facet-file",
|
|
444
443
|
scope: "layout",
|
|
445
444
|
severity: "warning",
|
|
446
445
|
file: sourceFile.file,
|
|
447
|
-
message: `Unexpected lib root file "${
|
|
446
|
+
message: `Unexpected lib facet root file "${libFacetFile}". Keep direct lib/ root files limited to generated support facets.`,
|
|
448
447
|
});
|
|
449
448
|
}
|
|
450
449
|
|
|
@@ -669,7 +668,7 @@ function isRestrictedInternalKind(kind: ComponentFileDeclaration["kind"]) {
|
|
|
669
668
|
|
|
670
669
|
function isAllowedComponentExport(declaration: ComponentFileDeclaration, isPage: boolean) {
|
|
671
670
|
if (isComponentValueKind(declaration.kind) && isPascalCaseName(declaration.name)) return true;
|
|
672
|
-
return isPage &&
|
|
671
|
+
return isPage && RESERVED_ROUTE_CONFIG_EXPORTS.has(declaration.name);
|
|
673
672
|
}
|
|
674
673
|
|
|
675
674
|
function isPascalCaseName(name: string) {
|
|
@@ -838,7 +837,19 @@ function getConventionDescription(suffix: (typeof CONVENTION_SUFFIXES)[number],
|
|
|
838
837
|
return `${modelName}Store`;
|
|
839
838
|
}
|
|
840
839
|
|
|
841
|
-
|
|
840
|
+
const sysRootTypes = { apps: "app", libs: "lib" } as const;
|
|
841
|
+
|
|
842
|
+
function getSysRootEntry(file: string) {
|
|
843
|
+
const segments = file.split("/");
|
|
844
|
+
const root = segments[0];
|
|
845
|
+
const name = segments[2];
|
|
846
|
+
if (!root || !name || segments.length < 3) return null;
|
|
847
|
+
const type = sysRootTypes[root as keyof typeof sysRootTypes];
|
|
848
|
+
if (!type) return null;
|
|
849
|
+
return { type, name, isDir: segments.length > 3 };
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function getLibFacetRootFile(file: string) {
|
|
842
853
|
const segments = file.split("/");
|
|
843
854
|
if ((segments[0] === "libs" || segments[0] === "apps") && segments.length === 4 && segments[2] === "lib")
|
|
844
855
|
return segments[3];
|
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
|
@@ -9,9 +9,8 @@ import type {
|
|
|
9
9
|
PkgScanResult,
|
|
10
10
|
ScanResult,
|
|
11
11
|
} from "./akanConfig";
|
|
12
|
-
|
|
13
12
|
import { AppExecutor, LibExecutor, PkgExecutor, WorkspaceExecutor } from "./executors";
|
|
14
|
-
import {
|
|
13
|
+
import { isAllowedLibFacetRootFile, rootAllowedDirs, rootAllowedFiles } from "./workspaceLayout";
|
|
15
14
|
|
|
16
15
|
const scalarFileTypes = ["constant", "dictionary", "document", "template", "unit", "util", "view", "zone"] as const;
|
|
17
16
|
type ScalarFileType = (typeof scalarFileTypes)[number];
|
|
@@ -57,7 +56,6 @@ const moduleUiFileTypes = {
|
|
|
57
56
|
scalar: new Set(["Template", "Unit"]),
|
|
58
57
|
} satisfies Record<ModuleKind, Set<string>>;
|
|
59
58
|
const testFilePattern = /\.(test|spec)\.(ts|tsx)$/;
|
|
60
|
-
const rootSignalTestFilePattern = /^[A-Za-z][A-Za-z0-9_-]*\.signal\.(test|spec)\.(ts|tsx)$/;
|
|
61
59
|
|
|
62
60
|
// The dependency scanner needs `typescript` (~65MB resident). Only the scan/sync commands run it, so
|
|
63
61
|
// load it on demand rather than through the module graph of every process that imports scan info.
|
|
@@ -65,8 +63,6 @@ const createDependencyScanner = async (exec: AppExecutor | LibExecutor | PkgExec
|
|
|
65
63
|
(await import("./dependencyScanner")).TypeScriptDependencyScanner.from(exec);
|
|
66
64
|
|
|
67
65
|
const isAllowedTestFile = (filename: string) => testFilePattern.test(filename);
|
|
68
|
-
const isAllowedLibRootFile = (filename: string) =>
|
|
69
|
-
libFacetRootAllowedFiles.has(filename) || rootSignalTestFilePattern.test(filename);
|
|
70
66
|
const getScanPath = (exec: AppExecutor | LibExecutor, relativePath: string) =>
|
|
71
67
|
path.posix.join(`${exec.type}s`, exec.name, relativePath.split(path.sep).join("/"));
|
|
72
68
|
async function clearGeneratedRootCapacitorConfigs(exec: AppExecutor | LibExecutor) {
|
|
@@ -85,24 +81,29 @@ async function assertScanConvention(exec: AppExecutor | LibExecutor, libRoot: {
|
|
|
85
81
|
violations.push(`${getScanPath(exec, relativePath)}: ${reason}`);
|
|
86
82
|
};
|
|
87
83
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
});
|
|
100
|
-
|
|
84
|
+
const allowedRootFiles: ReadonlySet<string> = rootAllowedFiles[exec.type];
|
|
85
|
+
const allowedRootDirs: ReadonlySet<string> = rootAllowedDirs[exec.type];
|
|
86
|
+
const { files, dirs } = await exec.getFilesAndDirs(".");
|
|
87
|
+
files
|
|
88
|
+
.filter((filename) => !allowedRootFiles.has(filename))
|
|
89
|
+
.forEach((filename) => {
|
|
90
|
+
addViolation(filename, `unsupported ${exec.type} root file`);
|
|
91
|
+
});
|
|
92
|
+
dirs
|
|
93
|
+
.filter((dirname) => !allowedRootDirs.has(dirname))
|
|
94
|
+
.forEach((dirname) => {
|
|
95
|
+
addViolation(dirname, `unsupported ${exec.type} root folder`);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
//* An app validates its page tree in `getPageKeys`, which a lib has no equivalent of — so a lib's own
|
|
99
|
+
//* routes went unchecked until whichever app opted into them with `syncPageLibs` blew up on the sync.
|
|
100
|
+
if (exec.type === "lib")
|
|
101
|
+
for (const { relativePath, reason } of await exec.getPageConventionViolations()) addViolation(relativePath, reason);
|
|
101
102
|
|
|
102
103
|
libRoot.files
|
|
103
|
-
.filter((filename) => !
|
|
104
|
+
.filter((filename) => !isAllowedLibFacetRootFile(filename))
|
|
104
105
|
.forEach((filename) => {
|
|
105
|
-
addViolation(path.join("lib", filename), "unsupported lib root file");
|
|
106
|
+
addViolation(path.join("lib", filename), "unsupported lib facet root file");
|
|
106
107
|
});
|
|
107
108
|
|
|
108
109
|
libRoot.dirs
|