@akanjs/devkit 3.0.0-alpha.2 → 3.0.0-alpha.21
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 +317 -0
- package/biomeBase.ts +9 -0
- package/capacitorApp.test.ts +8 -0
- package/capacitorApp.ts +112 -18
- package/executors.test.ts +42 -5
- package/executors.ts +43 -4
- 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 +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/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/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 +16 -0
- package/lint/no-return-in-store-action.grit +35 -0
- package/linter.ts +17 -12
- package/package.json +4 -4
- package/qualityScanner.test.ts +23 -0
- package/qualityScanner.ts +5 -44
- package/repoIdentity.ts +42 -0
- package/routeSourceValidator.test.ts +41 -0
- package/routeSourceValidator.ts +2 -44
- package/scanInfo.ts +2 -43
- package/transforms/externalizeFrameworkPlugin.ts +0 -1
- package/workspaceLayout.test.ts +26 -0
- package/workspaceLayout.ts +60 -0
package/getCredentials.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import yaml from "js-yaml";
|
|
2
|
-
|
|
3
1
|
import type { AppExecutor } from "./executors";
|
|
4
2
|
import { FileSys } from "./fileSys";
|
|
5
3
|
|
|
@@ -12,7 +10,7 @@ interface Secret {
|
|
|
12
10
|
|
|
13
11
|
export const getCredentials = async (app: AppExecutor, environment: string): Promise<AppSecret> => {
|
|
14
12
|
const content = await FileSys.readText(`${app.workspace.workspaceRoot}/infra/app/values/${app.name}-secret.yaml`);
|
|
15
|
-
const secret =
|
|
13
|
+
const secret = Bun.YAML.parse(content) as Secret;
|
|
16
14
|
const appSecret = secret[environment];
|
|
17
15
|
if (!appSecret) throw new Error(`No secret found for ${app.name} in ${environment}`);
|
|
18
16
|
return appSecret;
|
|
@@ -22,27 +22,25 @@ describe("prepareDevWatchBatch", () => {
|
|
|
22
22
|
expect(prepared.event.devPlan?.files).toEqual(prepared.files);
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
-
test.each([
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
indexSync: { changedFiles: [generatedIndex], errors: [] },
|
|
38
|
-
changePlanner: new DevChangePlanner({ workspaceRoot: root }),
|
|
39
|
-
});
|
|
25
|
+
test.each(["common", "srvkit", "ui", "webkit"])(
|
|
26
|
+
"keeps %s facet add/delete generated index in the same generation",
|
|
27
|
+
(facet) => {
|
|
28
|
+
const root = "/repo";
|
|
29
|
+
const changedFile = `${root}/libs/shared/${facet}/tmpExample.ts`;
|
|
30
|
+
const generatedIndex = `${root}/libs/shared/${facet}/index.ts`;
|
|
31
|
+
const prepared = prepareDevWatchBatch({
|
|
32
|
+
generation: 20,
|
|
33
|
+
batch: { files: [changedFile], kinds: new Set(["code"]) },
|
|
34
|
+
indexSync: { changedFiles: [generatedIndex], errors: [] },
|
|
35
|
+
changePlanner: new DevChangePlanner({ workspaceRoot: root }),
|
|
36
|
+
});
|
|
40
37
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
38
|
+
expect(new Set(prepared.files)).toEqual(new Set([changedFile, generatedIndex]));
|
|
39
|
+
expect(prepared.event.devPlan?.generatedFiles).toEqual([generatedIndex]);
|
|
40
|
+
expect(prepared.event.devPlan?.roles).toContain("barrel");
|
|
41
|
+
expect(prepared.event.devPlan?.actions).toContain("sync-generated");
|
|
42
|
+
},
|
|
43
|
+
);
|
|
46
44
|
|
|
47
45
|
test("marks failed generated index sync as an error generation", () => {
|
|
48
46
|
const root = "/repo";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// Bun's bundler classifies `//!` and `/*!` as legal comments (the `@license` / `@preserve` class) and keeps
|
|
5
|
+
// them through `minify: true`, so a `//!` marker in browser-reachable code ships verbatim to every visitor.
|
|
6
|
+
// `legalComments` is not a Bun.build option, so the source is the only place to stop it.
|
|
7
|
+
// The two alternatives anchor the marker to line start or to whitespace after code, which keeps a literal
|
|
8
|
+
// like `'https://host//!path'` from tripping the rule. A marker on the file's very first line is trivia that
|
|
9
|
+
// Biome does not expose to the pattern, so it is the one case this rule cannot see.
|
|
10
|
+
JsModule() as $mod where {
|
|
11
|
+
or {
|
|
12
|
+
$mod <: r"(?m)(?s).*^[ \t]*//!.*",
|
|
13
|
+
$mod <: r"(?s).*[^\s/][ \t]+//!.*"
|
|
14
|
+
},
|
|
15
|
+
register_diagnostic(
|
|
16
|
+
span = $mod,
|
|
17
|
+
message = "The `//!` marker survives minification (Bun keeps it as a legal comment) and ships to the browser. Use `// FIXME:` or `// TODO:` in client-reachable code; keep `//!` for server, srvkit, and CLI files.",
|
|
18
|
+
severity = "error"
|
|
19
|
+
)
|
|
20
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// Server files (*.document.ts, *.dictionary.ts, *.service.ts, *.signal.ts, srvkit/) and shared files
|
|
5
|
+
// (common/, *.constant.ts) run in Bun with no DOM and no bundler, and every CLI command, worker, and
|
|
6
|
+
// migration that loads a service loads whatever the service imports. Reaching into the client graph
|
|
7
|
+
// pulls React, the store, and the browser globals they touch into that process, and it runs the
|
|
8
|
+
// dependency backwards: the client entrypoint is built on top of cnst and sig, not the other way round.
|
|
9
|
+
//
|
|
10
|
+
// `import type` is erased before bundling and stays legal. A mixed value-and-type import is not exempt.
|
|
11
|
+
or {
|
|
12
|
+
JsModuleSource() as $source where {
|
|
13
|
+
$source <: within JsImport() as $import,
|
|
14
|
+
not $import <: r"import\s+type[\s\S]*",
|
|
15
|
+
$source <: or {
|
|
16
|
+
r"\".*\.store\"",
|
|
17
|
+
r"\".*\.(?:Template|Unit|Util|View|Zone)\""
|
|
18
|
+
},
|
|
19
|
+
register_diagnostic(
|
|
20
|
+
span = $source,
|
|
21
|
+
message = "Client module. A server or shared file must not import a *.store or a module component (*.Template, *.Unit, *.Util, *.View, *.Zone) — server code reaches the model through cnst and db, never through client state or JSX.",
|
|
22
|
+
severity = "error"
|
|
23
|
+
)
|
|
24
|
+
},
|
|
25
|
+
JsModuleSource() as $source where {
|
|
26
|
+
$source <: within JsImport() as $import,
|
|
27
|
+
not $import <: r"import\s+type[\s\S]*",
|
|
28
|
+
$source <: or {
|
|
29
|
+
r"\"(?:.*/)?(?:ui|webkit)(?:/.*)?\"",
|
|
30
|
+
r"\".*/client(?:/.*)?\""
|
|
31
|
+
},
|
|
32
|
+
register_diagnostic(
|
|
33
|
+
span = $source,
|
|
34
|
+
message = "Client entrypoint. A server or shared file must not import ui/, webkit/, or a package client entrypoint such as '@libs/<lib>/client' or 'akanjs/client' — import the server entrypoint or a common/ helper instead.",
|
|
35
|
+
severity = "error"
|
|
36
|
+
)
|
|
37
|
+
},
|
|
38
|
+
JsModuleSource() as $source where {
|
|
39
|
+
$source <: within JsImport() as $import,
|
|
40
|
+
not $import <: r"import\s+type[\s\S]*",
|
|
41
|
+
$source <: r"\"(?:.*/)?(?:st|store|useClient)\"",
|
|
42
|
+
register_diagnostic(
|
|
43
|
+
span = $source,
|
|
44
|
+
message = "Client barrel. A server or shared file must not import st, store, or useClient — server code holds no client state, and Err comes from dict on the server.",
|
|
45
|
+
severity = "error"
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// Client files (ui/, webkit/, page/, *.store.ts, every .tsx) and shared files (common/, *.constant.ts)
|
|
5
|
+
// are compiled into the browser bundle, so a single value import of a server module drags its whole
|
|
6
|
+
// graph along — the database driver, node:crypto, a secret resolved from process.env. The boundary has
|
|
7
|
+
// to hold at the import statement, because by the call site the module is already bundled.
|
|
8
|
+
//
|
|
9
|
+
// `import type` is erased before bundling and stays legal, so a shared file may still name a server-side
|
|
10
|
+
// type. A mixed value-and-type import is not exempt: it emits a real edge.
|
|
11
|
+
or {
|
|
12
|
+
JsModuleSource() as $source where {
|
|
13
|
+
$source <: within JsImport() as $import,
|
|
14
|
+
not $import <: r"import\s+type[\s\S]*",
|
|
15
|
+
$source <: r"\".*\.(?:document|dictionary|service|signal)\"",
|
|
16
|
+
register_diagnostic(
|
|
17
|
+
span = $source,
|
|
18
|
+
message = "Server module. A client or shared file must not import a *.document, *.dictionary, *.service, or *.signal file — take the model from the package client entrypoint ('@libs/<lib>/client') or from its *.constant instead. Write 'import type' if only the type is needed.",
|
|
19
|
+
severity = "error"
|
|
20
|
+
)
|
|
21
|
+
},
|
|
22
|
+
JsModuleSource() as $source where {
|
|
23
|
+
$source <: within JsImport() as $import,
|
|
24
|
+
not $import <: r"import\s+type[\s\S]*",
|
|
25
|
+
$source <: or {
|
|
26
|
+
r"\"(?:.*/)?srvkit(?:/.*)?\"",
|
|
27
|
+
r"\".*/server(?:/.*)?\""
|
|
28
|
+
},
|
|
29
|
+
register_diagnostic(
|
|
30
|
+
span = $source,
|
|
31
|
+
message = "Server entrypoint. A client or shared file must not import srvkit/ or a package server entrypoint such as '@apps/<app>/server' or 'akanjs/server' — import the matching client entrypoint instead.",
|
|
32
|
+
severity = "error"
|
|
33
|
+
)
|
|
34
|
+
},
|
|
35
|
+
JsModuleSource() as $source where {
|
|
36
|
+
$source <: within JsImport() as $import,
|
|
37
|
+
not $import <: r"import\s+type[\s\S]*",
|
|
38
|
+
$source <: r"\"(?:.*/)?(?:db|srv|sig|dict|option|useServer)\"",
|
|
39
|
+
register_diagnostic(
|
|
40
|
+
span = $source,
|
|
41
|
+
message = "Server barrel. A client or shared file must not import db, srv, sig, dict, option, or useServer — read models from cnst, state from st, and Err from the package client entrypoint.",
|
|
42
|
+
severity = "error"
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
TsReferenceType() as $ref where {
|
|
5
|
+
$ref <: r"cnst\.[A-Za-z_$][A-Za-z0-9_$]*",
|
|
6
|
+
not $ref <: within TsIndexedAccessType() as $indexed where {
|
|
7
|
+
$indexed <: r"cnst\.[A-Za-z_$][A-Za-z0-9_$]*\[.value.\][\s\S]*"
|
|
8
|
+
},
|
|
9
|
+
not $ref <: within TsReferenceType() as $wrapper where {
|
|
10
|
+
$wrapper <: r"(?:Client|Server)(?:Init|View|Edit)<[\s\S]*"
|
|
11
|
+
},
|
|
12
|
+
register_diagnostic(
|
|
13
|
+
span = $ref,
|
|
14
|
+
message = "Util and Zone are client components, so a cnst model type here is a class instance crossing the server boundary. Take an id instead (bannerId: string), or hand server data across as ClientInit / ClientView. An enum union such as cnst.AdminRole['value'] is allowed."
|
|
15
|
+
)
|
|
16
|
+
}
|
|
@@ -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
|
+
}
|
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.21",
|
|
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.21",
|
|
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
|
@@ -183,3 +183,26 @@ describe("AkanQualityScanner ssr rules", () => {
|
|
|
183
183
|
expect(ssrBalance[2]).toMatchObject({ scope: "workspace", serverMass: 3, clientMass: 1 });
|
|
184
184
|
});
|
|
185
185
|
});
|
|
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
|
+
|
|
196
|
+
describe("AkanQualityScanner layout rules", () => {
|
|
197
|
+
test("flags an unknown app root file but not a facet entrypoint", async () => {
|
|
198
|
+
const root = await makeWorkspace({
|
|
199
|
+
"apps/demo/client.ts": "export const client = 1;\n",
|
|
200
|
+
"apps/demo/helper.ts": "export const helper = 1;\n",
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.layout.app-root-file");
|
|
204
|
+
|
|
205
|
+
expect(warnings).toHaveLength(1);
|
|
206
|
+
expect(warnings[0]?.file).toBe("apps/demo/helper.ts");
|
|
207
|
+
});
|
|
208
|
+
});
|
package/qualityScanner.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
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";
|
|
7
8
|
import { formatSsrBalance, type SsrBalanceEntry, SsrScanner } from "./ssrScanner";
|
|
9
|
+
import { appRootAllowedFiles, libFacetRootAllowedFiles } from "./workspaceLayout";
|
|
8
10
|
|
|
9
11
|
type QualitySeverity = "warning";
|
|
10
12
|
type QualityScope = "global" | "file" | "convention" | "layout" | "ssr";
|
|
@@ -90,29 +92,6 @@ const SUGGESTED_RULES = [
|
|
|
90
92
|
"Avoid large mixed-purpose class files; class export files should import helpers from neighboring utility files instead of declaring them inline.",
|
|
91
93
|
];
|
|
92
94
|
|
|
93
|
-
const APP_ROOT_FILES = new Set([
|
|
94
|
-
"akan.app.json",
|
|
95
|
-
"akan.config.ts",
|
|
96
|
-
"capacitor.config.ts",
|
|
97
|
-
"client.ts",
|
|
98
|
-
"main.ts",
|
|
99
|
-
"package.json",
|
|
100
|
-
"server.ts",
|
|
101
|
-
"tsconfig.json",
|
|
102
|
-
]);
|
|
103
|
-
|
|
104
|
-
const LIB_ROOT_FILES = new Set([
|
|
105
|
-
"cnst.ts",
|
|
106
|
-
"db.ts",
|
|
107
|
-
"dict.ts",
|
|
108
|
-
"option.ts",
|
|
109
|
-
"sig.ts",
|
|
110
|
-
"srv.ts",
|
|
111
|
-
"st.ts",
|
|
112
|
-
"useClient.ts",
|
|
113
|
-
"useServer.ts",
|
|
114
|
-
]);
|
|
115
|
-
|
|
116
95
|
const CONVENTION_SUFFIXES = [
|
|
117
96
|
".constant.ts",
|
|
118
97
|
".dictionary.ts",
|
|
@@ -122,24 +101,6 @@ const CONVENTION_SUFFIXES = [
|
|
|
122
101
|
".store.ts",
|
|
123
102
|
] as const;
|
|
124
103
|
|
|
125
|
-
// Non-PascalCase exports the framework recognizes on page/layout route modules (see PageModule/LayoutModule
|
|
126
|
-
// in pkgs/akanjs/client/csrTypes.ts). PascalCase route exports (Loading, NotFound, Error) pass the component
|
|
127
|
-
// check, and the `default` export is handled separately.
|
|
128
|
-
const PAGE_RESERVED_EXPORTS = new Set([
|
|
129
|
-
"pageConfig",
|
|
130
|
-
"head",
|
|
131
|
-
"metadata",
|
|
132
|
-
"generateHead",
|
|
133
|
-
"generateMetadata",
|
|
134
|
-
"fonts",
|
|
135
|
-
"manifest",
|
|
136
|
-
"theme",
|
|
137
|
-
"reconnect",
|
|
138
|
-
"wsConnect",
|
|
139
|
-
"layoutStyle",
|
|
140
|
-
"gaTrackingId",
|
|
141
|
-
]);
|
|
142
|
-
|
|
143
104
|
// How to remediate each rule, keyed by rule id. Surfaced as a `fix:` line per warning (text + JSON output)
|
|
144
105
|
// so the scan result tells the reader what to do, not just what is wrong.
|
|
145
106
|
const RULE_FIXES: Record<string, string> = {
|
|
@@ -449,7 +410,7 @@ export class AkanQualityScanner {
|
|
|
449
410
|
#scanLayoutQuality(sourceFile: SourceFileInfo): QualityWarning[] {
|
|
450
411
|
const segments = sourceFile.file.split("/");
|
|
451
412
|
const warnings: QualityWarning[] = [];
|
|
452
|
-
if (segments[0] === "apps" && segments.length === 3 && !
|
|
413
|
+
if (segments[0] === "apps" && segments.length === 3 && !appRootAllowedFiles.has(segments[2])) {
|
|
453
414
|
warnings.push({
|
|
454
415
|
rule: "akan.layout.app-root-file",
|
|
455
416
|
scope: "layout",
|
|
@@ -460,7 +421,7 @@ export class AkanQualityScanner {
|
|
|
460
421
|
}
|
|
461
422
|
|
|
462
423
|
const libRootFile = getLibRootFile(sourceFile.file);
|
|
463
|
-
if (libRootFile && !
|
|
424
|
+
if (libRootFile && !libFacetRootAllowedFiles.has(libRootFile)) {
|
|
464
425
|
warnings.push({
|
|
465
426
|
rule: "akan.layout.lib-root-file",
|
|
466
427
|
scope: "layout",
|
|
@@ -691,7 +652,7 @@ function isRestrictedInternalKind(kind: ComponentFileDeclaration["kind"]) {
|
|
|
691
652
|
|
|
692
653
|
function isAllowedComponentExport(declaration: ComponentFileDeclaration, isPage: boolean) {
|
|
693
654
|
if (isComponentValueKind(declaration.kind) && isPascalCaseName(declaration.name)) return true;
|
|
694
|
-
return isPage &&
|
|
655
|
+
return isPage && RESERVED_ROUTE_CONFIG_EXPORTS.has(declaration.name);
|
|
695
656
|
}
|
|
696
657
|
|
|
697
658
|
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)) {
|