@akanjs/devkit 3.0.0-alpha.5 → 3.0.0-alpha.51
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 +325 -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-async-component-in-ui.grit +35 -0
- 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
|
+
// React has no async client component: an async body is a server-only shape, so a `ui/` component that
|
|
5
|
+
// awaits stops rendering the moment any client parent imports it — and `ui/` is written to be reachable
|
|
6
|
+
// from both sides. It also moves the load below the route, where the framework can no longer start it
|
|
7
|
+
// before the first byte. Awaiting belongs to `page/**`, which passes the resolved data down as props.
|
|
8
|
+
//
|
|
9
|
+
// Component-ness is a PascalCase regex on the binding, the same test `no-model-type-in-util-zone.grit`
|
|
10
|
+
// uses. The declarator regex anchors `async` to the initializer position (`[^=]*` cannot cross an `=`),
|
|
11
|
+
// so an async handler declared inside a synchronous component is not read as the component's own body;
|
|
12
|
+
// `not within JsFunctionBody()` drops every nested declaration for the same reason. Out of scope by
|
|
13
|
+
// construction, since neither is a named binding at statement level: an inline `onClick={async () => …}`
|
|
14
|
+
// and an async member of an object literal.
|
|
15
|
+
JsIdentifierBinding() as $name where {
|
|
16
|
+
$name <: r"[A-Z][A-Za-z0-9_$]*",
|
|
17
|
+
not $name <: within JsFunctionBody(),
|
|
18
|
+
not $name <: within JsFormalParameter(),
|
|
19
|
+
or {
|
|
20
|
+
$name <: within JsVariableDeclarator() as $decl where {
|
|
21
|
+
$decl <: r"[A-Z][A-Za-z0-9_$]*\s*(?::[^=]*)?=\s*async[\s\S]*"
|
|
22
|
+
},
|
|
23
|
+
$name <: within JsFunctionDeclaration() as $fn where {
|
|
24
|
+
$fn <: r"(?:export\s+)?(?:default\s+)?async\s+function[\s\S]*"
|
|
25
|
+
},
|
|
26
|
+
$name <: within JsFunctionExportDefaultDeclaration() as $default where {
|
|
27
|
+
$default <: r"(?:export\s+)?(?:default\s+)?async\s+function[\s\S]*"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
register_diagnostic(
|
|
31
|
+
span = $name,
|
|
32
|
+
message = "An async component belongs in page/. React has no async client component, so a ui/ component that awaits breaks as soon as a client parent renders it, and the route can no longer start the load before the first byte. Await in the page — or hand an unawaited fetch.* to a Zone as an init / view prop — and take the resolved data as a prop.",
|
|
33
|
+
severity = "error"
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -2,19 +2,36 @@ engine biome(1.0)
|
|
|
2
2
|
language js(typescript, jsx)
|
|
3
3
|
|
|
4
4
|
// daisyUI was removed from the UI system; its component classes (`btn-primary`, `card-body`,
|
|
5
|
-
// `mockup-code`, ...)
|
|
6
|
-
//
|
|
5
|
+
// `mockup-code`, ...) and its colour slots (`bg-base-100`, `text-base-content`, `bg-error`)
|
|
6
|
+
// no longer have any CSS behind them and silently render unstyled. Grit port of styleGuard's
|
|
7
|
+
// `daisyui-legacy` rule (frontendBuild/styleGuard.ts) — keep in sync.
|
|
7
8
|
// Only high-signal compound names are matched; bare ambiguous words that collide with
|
|
8
|
-
// Tailwind or app classes (`card`, `input`, `badge`, `btn`) are deliberately not
|
|
9
|
+
// Tailwind or app classes (`card`, `input`, `badge`, `btn`, `divider`) are deliberately not
|
|
10
|
+
// flagged, and colour slots that survived into the semantic vocabulary (`primary`, `info`,
|
|
11
|
+
// `success`, `warning`, `neutral`) are not either — only the slots the vocabulary dropped.
|
|
12
|
+
// Biome keeps only the first diagnostic a single plugin registers on a node, so a string
|
|
13
|
+
// carrying both halves reports the component one and surfaces the colour one on the next run.
|
|
9
14
|
or {
|
|
10
15
|
JsxString() as $s,
|
|
11
16
|
JsStringLiteralExpression() as $s,
|
|
12
17
|
JsTemplateChunkElement() as $s
|
|
13
18
|
} where {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
any {
|
|
20
|
+
and {
|
|
21
|
+
$s <: r"[\s\S]*(?:^|[\s\"'`{(\[:!])(?:btn-(?:primary|secondary|accent|neutral|info|success|warning|error|ghost|link|outline|square|circle|wide|block|xs|sm|md|lg)|badge-(?:primary|secondary|accent|neutral|info|success|warning|error|ghost|outline)|alert-(?:info|success|warning|error)|input-(?:bordered|primary|secondary|accent|ghost|error)|select-(?:bordered|primary|ghost)|textarea-(?:bordered|primary|ghost)|checkbox-(?:primary|secondary|accent)|toggle-(?:primary|secondary|accent)|loading-(?:spinner|dots|ring|ball|bars|infinity)|card-(?:body|title|actions)|modal-(?:box|action|backdrop)|collapse-(?:title|content|arrow|plus)|dropdown-(?:content|end|start|hover)|stat-(?:title|value|desc)|tabs-(?:boxed|lifted|bordered)|tab-active|menu-(?:title|dropdown)|steps-(?:horizontal|vertical)|join-item|mockup-(?:code|phone|browser|window)|drawer-(?:side|content|toggle))(?:[\s\"'`})\]:/!,]|$)[\s\S]*",
|
|
22
|
+
register_diagnostic(
|
|
23
|
+
span = $s,
|
|
24
|
+
message = "daisyUI legacy class - daisyUI was removed, so this renders unstyled. Use akanjs/ui components and recipes (Button, buttonRecipe, badgeRecipe, ...) with semantic tokens instead.",
|
|
25
|
+
severity = "error"
|
|
26
|
+
)
|
|
27
|
+
},
|
|
28
|
+
and {
|
|
29
|
+
$s <: r"[\s\S]*(?:^|[\s\"'`{(\[:!])(?:bg|text|border(?:-[tblrxy])?(?:-[se])?|ring(?:-offset)?|fill|stroke|shadow|from|to|via|divide|outline|decoration|placeholder|caret|accent)-(?:base-(?:100|200|300|content)|(?:primary|secondary|accent|neutral|info|success|warning|error)-content|error)(?:[\s\"'`})\]:/!,]|$)[\s\S]*",
|
|
30
|
+
register_diagnostic(
|
|
31
|
+
span = $s,
|
|
32
|
+
message = "daisyUI colour slot - the closed colour vocabulary has no CSS for it, so this renders unstyled. base-100/200/300 -> background/muted/border, base-content -> foreground, <colour>-content -> <colour>-foreground, error -> destructive.",
|
|
33
|
+
severity = "error"
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
20
37
|
}
|
|
@@ -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,58 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// `Util` and `Zone` are always client components, so a *prop* typed as a `cnst` model is a hydrated
|
|
5
|
+
// class instance the server has to hand across the boundary — the methods are stripped on the way and
|
|
6
|
+
// what arrives is a plain object wearing the model's type.
|
|
7
|
+
//
|
|
8
|
+
// Only prop positions are reported: the component's `*Props` interface / type alias, or the inline
|
|
9
|
+
// object type on its parameter. A `cnst` type anywhere else in the file never crosses the boundary and
|
|
10
|
+
// stays legal — a local annotation, a callback parameter the framework already types with the model
|
|
11
|
+
// (`renderItem`, `renderList`), a module-scope helper, a non-`Props` local shape, and the props of a
|
|
12
|
+
// component nested inside another one. A function-typed prop is client-internal too: a closure cannot
|
|
13
|
+
// cross the RSC boundary at all, so whoever passes it already holds the value it takes.
|
|
14
|
+
//
|
|
15
|
+
// The exempt generics are the framework props types whose model parameter never lands on a prop as
|
|
16
|
+
// data: `ClientInit` / `ClientView` / `ClientEdit` (mapped to plain `GetStateObject`) and `ModelsProps`,
|
|
17
|
+
// whose only use of the model is `onClickItem?: (model: M) => unknown` — a callback, so the caller
|
|
18
|
+
// already holds the value. `ModelProps<"x", cnst.LightX>` is *not* exempt: it spreads the model onto
|
|
19
|
+
// the props themselves.
|
|
20
|
+
TsReferenceType() as $ref where {
|
|
21
|
+
$ref <: r"cnst\.[A-Za-z_$][A-Za-z0-9_$]*",
|
|
22
|
+
not $ref <: within TsIndexedAccessType() as $indexed where {
|
|
23
|
+
$indexed <: r"cnst\.[A-Za-z_$][A-Za-z0-9_$]*\[.value.\][\s\S]*"
|
|
24
|
+
},
|
|
25
|
+
not $ref <: within TsReferenceType() as $wrapper where {
|
|
26
|
+
$wrapper <: r"(?:(?:Client|Server)(?:Init|View|Edit)|ModelsProps)<[\s\S]*"
|
|
27
|
+
},
|
|
28
|
+
not $ref <: within TsFunctionType(),
|
|
29
|
+
or {
|
|
30
|
+
$ref <: within TsInterfaceDeclaration() as $propsInterface where {
|
|
31
|
+
$propsInterface <: r"interface\s+[A-Za-z0-9_$]*Props[\s\S]*"
|
|
32
|
+
},
|
|
33
|
+
$ref <: within TsTypeAliasDeclaration() as $propsAlias where {
|
|
34
|
+
$propsAlias <: r"type\s+[A-Za-z0-9_$]*Props[\s\S]*"
|
|
35
|
+
},
|
|
36
|
+
and {
|
|
37
|
+
$ref <: within JsFormalParameter(),
|
|
38
|
+
// A parameter is a sibling of the body, so this only drops the ones belonging to a function
|
|
39
|
+
// declared *inside* a component — a nested component or a local callback.
|
|
40
|
+
not $ref <: within JsFunctionBody(),
|
|
41
|
+
// Same for a callback handed straight to a call, which an expression-bodied component leaves
|
|
42
|
+
// outside every function body: `rows.map((row: cnst.LightTicket) => …)`.
|
|
43
|
+
not $ref <: within JsCallArguments(),
|
|
44
|
+
or {
|
|
45
|
+
$ref <: within JsVariableDeclarator() as $component where {
|
|
46
|
+
$component <: r"[A-Z][A-Za-z0-9_$]*[\s\S]*"
|
|
47
|
+
},
|
|
48
|
+
$ref <: within JsFunctionDeclaration() as $componentFn where {
|
|
49
|
+
$componentFn <: r"(?:async\s+)?function\s+[A-Z][A-Za-z0-9_$]*[\s\S]*"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
register_diagnostic(
|
|
55
|
+
span = $ref,
|
|
56
|
+
message = "Util and Zone are client components, so a cnst model type on a prop 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, and so is a cnst type that stays inside the file — a local annotation or a callback parameter."
|
|
57
|
+
)
|
|
58
|
+
}
|
|
@@ -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.51",
|
|
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.51",
|
|
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
|
});
|