@akanjs/devkit 3.0.0-alpha.1 → 3.0.0-alpha.100

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.
Files changed (194) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/DEV_RUNTIME_KNOBS.md +34 -0
  3. package/README.ko.md +1 -1
  4. package/README.md +1 -1
  5. package/agentsIndex.test.ts +10 -0
  6. package/agentsIndex.ts +47 -1
  7. package/aiEditor.ts +1 -1
  8. package/akanApp/BackendImportGraph.test.ts +120 -0
  9. package/akanApp/BackendImportGraph.ts +167 -0
  10. package/akanApp/akanApp.host.test.ts +16 -678
  11. package/akanApp/akanApp.host.ts +113 -578
  12. package/akanApp/devHostPolicy.test.ts +542 -0
  13. package/akanApp/devHostPolicy.ts +442 -0
  14. package/akanApp/index.ts +2 -0
  15. package/akanConfig/akanConfig.test.ts +182 -14
  16. package/akanConfig/akanConfig.ts +143 -47
  17. package/akanConfig/types.ts +9 -0
  18. package/akanContext.ts +70 -42
  19. package/akanMcpContract.ts +25 -4
  20. package/appSelectionMemory.ts +28 -0
  21. package/applicationBuildRunner.test.ts +37 -2
  22. package/applicationBuildRunner.ts +53 -24
  23. package/artifact/implicitRootLayout.test.ts +67 -0
  24. package/artifact/implicitRootLayout.ts +27 -7
  25. package/artifact/routeSeedIndex.test.ts +1 -0
  26. package/biome.base.json +340 -0
  27. package/biomeBase.ts +9 -0
  28. package/capacitorApp.test.ts +8 -0
  29. package/capacitorApp.ts +122 -18
  30. package/cloud/globalConfig.ts +9 -2
  31. package/codegenLock.test.ts +137 -0
  32. package/codegenLock.ts +136 -0
  33. package/commandDecorators/argMeta.ts +9 -1
  34. package/commandDecorators/command.ts +94 -7
  35. package/commandDecorators/commandBuilder.ts +20 -5
  36. package/commandDecorators/commandDecorators.test.ts +115 -3
  37. package/commandDecorators/helpFormatter.ts +4 -0
  38. package/dependencyScanner.test.ts +99 -0
  39. package/dependencyScanner.ts +27 -24
  40. package/devkitUtils.test.ts +0 -104
  41. package/executors.test.ts +137 -5
  42. package/executors.ts +116 -26
  43. package/fileEditor.ts +19 -19
  44. package/fileSys.ts +19 -1
  45. package/formSetterScanner.test.ts +80 -0
  46. package/formSetterScanner.ts +92 -0
  47. package/frontendBuild/allRoutesBuilder.ts +27 -8
  48. package/frontendBuild/autoImportSync.test.ts +58 -0
  49. package/frontendBuild/autoImportSync.ts +7 -2
  50. package/frontendBuild/buildRouteClient.test.ts +47 -24
  51. package/frontendBuild/clientBuildTypes.ts +4 -0
  52. package/frontendBuild/clientEntriesBundler.ts +4 -1
  53. package/frontendBuild/clientEntryDiscovery.ts +2 -2
  54. package/frontendBuild/csrArtifactBuilder.ts +116 -84
  55. package/frontendBuild/cssCandidateCache.test.ts +38 -0
  56. package/frontendBuild/cssCandidateCache.ts +15 -3
  57. package/frontendBuild/cssCompiler.ts +123 -12
  58. package/frontendBuild/cssImportResolver.ts +8 -7
  59. package/frontendBuild/devGeneratedIndexSync.ts +3 -2
  60. package/frontendBuild/fontOptimizer.ts +37 -18
  61. package/frontendBuild/fontPruner.test.ts +220 -0
  62. package/frontendBuild/fontPruner.ts +206 -0
  63. package/frontendBuild/frontendBuild.test.ts +178 -10
  64. package/frontendBuild/hmrWatcher.ts +1 -1
  65. package/frontendBuild/index.ts +1 -1
  66. package/frontendBuild/pagesBundleBuilder.ts +3 -3
  67. package/frontendBuild/pagesEntrySourceGenerator.ts +11 -88
  68. package/frontendBuild/precompressArtifacts.ts +35 -7
  69. package/frontendBuild/routeClientBuilder.ts +26 -7
  70. package/frontendBuild/sourceMtimeIndex.ts +1 -1
  71. package/frontendBuild/ssrBaseArtifactBuilder.ts +21 -4
  72. package/frontendBuild/styleContract.ts +14 -20
  73. package/frontendBuild/themeValidator.ts +22 -17
  74. package/frontendBuild/vendorSpecifiers.ts +1 -0
  75. package/frontendBuild/watchRootResolver.test.ts +129 -0
  76. package/frontendBuild/watchRootResolver.ts +66 -1
  77. package/incrementalBuilder/devWatchBatch.test.ts +18 -20
  78. package/incrementalBuilder/devWatchBatch.ts +6 -2
  79. package/incrementalBuilder/incrementalBuilder.host.ts +51 -7
  80. package/incrementalBuilder/incrementalBuilder.proc.ts +17 -6
  81. package/index.ts +0 -7
  82. package/integration/devStabilityHarness.ts +2 -10
  83. package/libSource.test.ts +112 -0
  84. package/libSource.ts +126 -0
  85. package/lint/__fixtures__/README.md +40 -0
  86. package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
  87. package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
  88. package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
  89. package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
  90. package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
  91. package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
  92. package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
  93. package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
  94. package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
  95. package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
  96. package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
  97. package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
  98. package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
  99. package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
  100. package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
  101. package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
  102. package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
  103. package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
  104. package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
  105. package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
  106. package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
  107. package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
  108. package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
  109. package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
  110. package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
  111. package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
  112. package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
  113. package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
  114. package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
  115. package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
  116. package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
  117. package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
  118. package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
  119. package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
  120. package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
  121. package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
  122. package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
  123. package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
  124. package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
  125. package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
  126. package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
  127. package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
  128. package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
  129. package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
  130. package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
  131. package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
  132. package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
  133. package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
  134. package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
  135. package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
  136. package/lint/gritRules.test.ts +178 -0
  137. package/lint/no-arbitrary-color.grit +1 -1
  138. package/lint/no-async-component-in-ui.grit +35 -0
  139. package/lint/no-bang-comment-in-client.grit +33 -0
  140. package/lint/no-daisyui-legacy-class.grit +26 -9
  141. package/lint/no-deprecated-log-level.grit +17 -0
  142. package/lint/no-import-client-in-server.grit +48 -0
  143. package/lint/no-import-server-in-client.grit +45 -0
  144. package/lint/no-init-fetch-in-client.grit +47 -0
  145. package/lint/no-inline-color.grit +10 -8
  146. package/lint/no-interpolated-arbitrary-class.grit +3 -3
  147. package/lint/no-model-type-in-util-zone.grit +58 -0
  148. package/lint/no-raw-palette-class.grit +3 -3
  149. package/lint/no-return-in-store-action.grit +35 -0
  150. package/lint/no-unpublished-form-setter.grit +41 -0
  151. package/lint/non-scalar-props-restricted.grit +16 -7
  152. package/linter.test.ts +80 -0
  153. package/linter.ts +99 -25
  154. package/package.json +6 -8
  155. package/prompter.ts +9 -4
  156. package/qualityScanner.test.ts +130 -0
  157. package/qualityScanner.ts +91 -62
  158. package/recipeScanner.ts +4 -1
  159. package/repoIdentity.ts +42 -0
  160. package/routeSourceValidator.test.ts +41 -0
  161. package/routeSourceValidator.ts +2 -44
  162. package/scanInfo.ts +32 -67
  163. package/semver.test.ts +26 -0
  164. package/semver.ts +31 -0
  165. package/slicePlanner.test.ts +182 -0
  166. package/slicePlanner.ts +235 -0
  167. package/ssrScanner.test.ts +301 -0
  168. package/subspace.test.ts +418 -0
  169. package/subspace.ts +723 -0
  170. package/subspaceConfig.ts +76 -0
  171. package/transforms/asyncDefaultExportDetector.ts +103 -0
  172. package/transforms/barrelImportsPlugin.ts +1 -1
  173. package/transforms/externalizeFrameworkPlugin.ts +0 -1
  174. package/transforms/transforms.test.ts +5 -5
  175. package/transforms/tsconfigPackageResolver.test.ts +230 -0
  176. package/tsconfig.json +6 -2
  177. package/typeChecker.ts +1 -1
  178. package/types.ts +1 -0
  179. package/uploadRelease.ts +2 -2
  180. package/workflow/executor.test.ts +146 -0
  181. package/workspaceLayout.test.ts +78 -0
  182. package/workspaceLayout.ts +106 -0
  183. package/builder.ts +0 -164
  184. package/extractDeps.ts +0 -86
  185. package/frontendBuild/styleGuard.test.ts +0 -165
  186. package/frontendBuild/styleGuard.ts +0 -322
  187. package/getCredentials.ts +0 -19
  188. package/getModelFileData.ts +0 -62
  189. package/src/capacitorApp.ts +0 -282
  190. package/streamAi.ts +0 -45
  191. package/ui/MultiScrollList.tsx +0 -242
  192. package/ui/ScrollList.tsx +0 -107
  193. package/ui/index.ts +0 -2
  194. package/useStdoutDimensions.ts +0 -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,47 @@
1
+ engine biome(1.0)
2
+ language js(typescript, jsx)
3
+
4
+ // `fetch.init<Model><Suffix>` is not a request, it is a hydration snapshot. `#registerSlice`
5
+ // (pkgs/akanjs/fetch/client/fetchClient.ts) composes it out of the slice's list and insight queries and
6
+ // returns a `ServerInit` whose only consumer is the `init` prop of `Load.Units` / `Load.View`, which
7
+ // writes it into the store before React ever renders. `fetch.get<Model>Init<Suffix>` is the same call
8
+ // returning the payload alone. Run from a route they resolve before the first byte and the markup ships
9
+ // populated; run after hydration they are two extra round-trips for a shell the browser already painted
10
+ // empty, landing in a local variable no store reads — `Load.*` seeds state, so a value held in a client
11
+ // closure reaches nothing.
12
+ //
13
+ // The client is not missing the load, only this spelling of it: every slice also generates
14
+ // `st.do.init<Model><Suffix>` (pkgs/akanjs/store/action.ts), which runs the same two queries and commits
15
+ // them to state.
16
+ //
17
+ // Both names are matched by shape, since a lint rule cannot know which slices exist. The generated one is
18
+ // `init` + `Capitalize<refName>` + `Capitalize<suffix>`, so it always carries two capital-led segments —
19
+ // requiring the second one is what keeps a hand-written `initPayment` / `initSession` endpoint out, while
20
+ // `initializeSomething` was never at risk (`init` is followed by a lowercase letter). What is left over is a
21
+ // custom endpoint that happens to spell the generated shape exactly, `initPaymentSession` or
22
+ // `get<X>Init<Y>`; suppress that one with `// biome-ignore lint/plugin: <reason>`.
23
+ //
24
+ // `view`/`edit` hydrate the same way but are not matched here: `edit<X>` is a plausible custom endpoint
25
+ // name, whereas the two shapes above are not.
26
+ //
27
+ // The gate is the file. `JsDirective` anchors `"use client"` to the real directive — docs pages carry the
28
+ // same text as an ordinary string literal and inside sample code, and neither is one. `*.store.ts` is
29
+ // added by name because a store is client-only by role and carries no directive.
30
+ JsCallExpression() as $call where {
31
+ $call <: `fetch.$endpoint($...)`,
32
+ $endpoint <: or {
33
+ r"init[A-Z][A-Za-z0-9_$]*[A-Z][A-Za-z0-9_$]*",
34
+ r"get[A-Z][A-Za-z0-9_$]*Init[A-Z][A-Za-z0-9_$]*"
35
+ },
36
+ or {
37
+ $filename <: r".*\.store\.ts",
38
+ $call <: within JsModule() as $module where {
39
+ $module <: contains JsDirective() as $directive where { $directive <: r"[\s\S]*use client[\s\S]*" }
40
+ }
41
+ },
42
+ register_diagnostic(
43
+ span = $call,
44
+ message = "This is a server-side hydration call. It composes the slice's list and insight queries into a snapshot whose only consumer is the init prop of Load.Units / Load.View, so on the client it costs two extra round-trips for a shell the browser already painted, and the result lands in a value nothing reads. Load it in the route — await fetch.initXInY(...) in page/** and pass the result down as an init prop, or hand the unawaited promise to a Zone and let Load.* resolve it behind a skeleton. To reload from the client, call the generated store action st.do.initXInY() instead, which writes the same data into state.",
45
+ severity = "error"
46
+ )
47
+ }
@@ -2,18 +2,20 @@ engine biome(1.0)
2
2
  language js(typescript, jsx)
3
3
 
4
4
  // Color literals inside `style={{ ... }}` bypass the class scanner, the semantic token layer,
5
- // and theme switching entirely. Grit port of styleGuard's `inline-color` rule
6
- // (frontendBuild/styleGuard.ts) — keep in sync. `var(--token)` references do not match.
5
+ // and theme switching entirely. `var(--token)` references do not match.
7
6
  //
8
- // Known delta vs the TS guard: color literals inside a `<style>` tag's template body are not
9
- // matched here (template chunks are not scoped by this pattern); the build-gate styleGuard
10
- // still covers that case.
11
- JsStringLiteralExpression() as $s where {
12
- $s <: within `style={$obj}`,
7
+ // The `<style>` arm covers the same literal in a tag body, which is the other way a color reaches
8
+ // the page without passing the vocabulary. Both arms are needed because the two sites are different
9
+ // node kinds — a string in an object property, and a chunk of a template literal.
10
+ // Cases: lint/__fixtures__/no-inline-color/{bad,good}.tsx
11
+ or {
12
+ JsStringLiteralExpression() as $s where { $s <: within `style={$obj}` },
13
+ JsTemplateChunkElement() as $s where { $s <: within `<style>$body</style>` }
14
+ } where {
13
15
  $s <: r"[\s\S]*(?:#[0-9a-fA-F]{3,8}|(?:rgb|rgba|hsl|hsla|oklch|oklab|lab|lch|hwb)\()[\s\S]*",
14
16
  register_diagnostic(
15
17
  span = $s,
16
- message = "Inline color literal in a style object bypasses semantic tokens and theme switching. Use a token class, or style={{ color: 'var(--primary)' }} when a runtime value is unavoidable.",
18
+ message = "Inline color literal bypasses semantic tokens and theme switching. Use a token class, or style={{ color: 'var(--primary)' }} when a runtime value is unavoidable.",
17
19
  severity = "error"
18
20
  )
19
21
  }
@@ -7,9 +7,9 @@ language js(typescript, jsx)
7
7
  // literal of the same shape happens to exist elsewhere in the codebase: the default value works by accident
8
8
  // while every override dies quietly (that is exactly how `Empty`'s minHeight went unnoticed).
9
9
  //
10
- // Grit port of styleGuard's `interpolated-arbitrary` rule (frontendBuild/styleGuard.ts) — keep the two
11
- // regexes in sync. Matching template *chunks* is what makes this detectable: the chunk before `${` still
12
- // carries the unterminated `-[`.
10
+ // Matching template *chunks* is what makes this detectable: the chunk before `${` still carries the
11
+ // unterminated `-[`.
12
+ // Cases: lint/__fixtures__/no-interpolated-arbitrary-class/{bad,good}.tsx
13
13
  //
14
14
  // The fix is a style prop for the runtime value (`style={{ minHeight }}`) — dimensions are not colors, so
15
15
  // no-inline-color does not apply — or a fixed set of literal classes when the value comes from an enum.
@@ -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
+ }
@@ -3,9 +3,9 @@ language js(typescript, jsx)
3
3
 
4
4
  // Vocabulary closure (`@theme { --color-*: initial }` in akanjs/ui/styles.css) strips the raw
5
5
  // Tailwind palette, so classes like `bg-blue-500` generate NO css and silently render as
6
- // currentColor/transparent. This rule surfaces that silent breakage at lint time. It is the
7
- // grit port of styleGuard's `raw-palette` rule (frontendBuild/styleGuard.ts) — keep the two
8
- // regexes in sync. Matching string/template nodes (not raw text) skips comments for free.
6
+ // currentColor/transparent. This rule surfaces that silent breakage at lint time. Matching
7
+ // string/template nodes (not raw text) skips comments for free.
8
+ // Cases: lint/__fixtures__/no-raw-palette-class/{bad,good}.tsx
9
9
  //
10
10
  // Bare `bg-neutral` is a semantic token and allowed; the numeric suffix (`bg-neutral-500`)
11
11
  // is what marks a raw palette class. black/white stay in the vocabulary for overlays.
@@ -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
+ }
@@ -1,10 +1,19 @@
1
- `$name={$value}` where {
2
- or {
3
- $value <: `($$$_) => $_`,
4
- $value <: `$_ => $_`,
5
- $value <: `function($$$_) { $$$_ }`,
6
- $value <: `function $fnName($$$_) { $$$_ }`
7
- },
1
+ engine biome(1.0)
2
+ language js(typescript, jsx)
3
+
4
+ // A function prop cannot cross the RSC boundary: a closure has no serialized form, so a server component
5
+ // handing one down fails at the boundary rather than at the call. `loader`, `render` and `of` are the
6
+ // framework's own slots, which take the function on the client side of the boundary.
7
+ //
8
+ // The attribute is matched by node fields rather than by a `$name={$value}` snippet: Biome's GritQL does
9
+ // not parse a bare snippet in that shape as a JsxAttribute, so the snippet form matches nothing at all.
10
+ // Reading `expression` off the initializer is also what keeps the value anchored to this attribute — a
11
+ // `contains` would reach into nested JSX and report `item={item}` inside `render={(item) => <Row item={item} />}`.
12
+ JsxAttribute(
13
+ name = $name,
14
+ initializer = JsxAttributeInitializerClause(value = JsxExpressionAttributeValue(expression = $value))
15
+ ) where {
16
+ $value <: or { JsArrowFunctionExpression(), JsFunctionExpression() },
8
17
  not $name <: or { `loader`, `render`, `of` },
9
18
  register_diagnostic(
10
19
  span = $name,
package/linter.test.ts ADDED
@@ -0,0 +1,80 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { type BiomeReport, parseBiomeReport } from "./linter";
3
+
4
+ const reportOf = (diagnostics: BiomeReport["diagnostics"]): string =>
5
+ JSON.stringify({ summary: { errors: diagnostics?.length ?? 0, warnings: 0 }, diagnostics });
6
+
7
+ const oneDiagnostic: BiomeReport["diagnostics"] = [
8
+ {
9
+ severity: "error",
10
+ message: "Unexpected any",
11
+ category: "lint/suspicious/noExplicitAny",
12
+ location: { path: "apps/demo/ui/Card.tsx", start: { line: 3, column: 10 } },
13
+ },
14
+ ];
15
+
16
+ describe("parseBiomeReport", () => {
17
+ test("reads a report that is the whole output", () => {
18
+ const report = parseBiomeReport(reportOf(oneDiagnostic));
19
+ expect(report.diagnostics).toHaveLength(1);
20
+ expect(report.diagnostics?.[0]?.category).toBe("lint/suspicious/noExplicitAny");
21
+ expect(report.summary?.errors).toBe(1);
22
+ });
23
+
24
+ test("reads a report behind leading text", () => {
25
+ const output = `Skipped 3 files.\n${reportOf(oneDiagnostic)}`;
26
+ expect(parseBiomeReport(output).diagnostics).toHaveLength(1);
27
+ });
28
+
29
+ test("reads a report behind leading text that carries a brace", () => {
30
+ // The whole reason the first-brace-to-last-brace slice was wrong: Biome prints configuration and IO
31
+ // diagnostics onto the same stream as the report, and those messages quote source and config.
32
+ const output = [
33
+ "configuration/deserialize: unknown key `overrides[0].includes { }`",
34
+ " the file `biome.jsonc` cannot be read",
35
+ reportOf(oneDiagnostic),
36
+ ].join("\n");
37
+ expect(parseBiomeReport(output).diagnostics).toHaveLength(1);
38
+ });
39
+
40
+ test("skips a complete JSON object in leading text that is not a report", () => {
41
+ const output = `hint: try {"linter":{"enabled":true}} in your config\n${reportOf(oneDiagnostic)}`;
42
+ const report = parseBiomeReport(output);
43
+ expect(report.diagnostics).toHaveLength(1);
44
+ expect(report.summary?.errors).toBe(1);
45
+ });
46
+
47
+ test("reads a report followed by trailing text", () => {
48
+ const output = `${reportOf(oneDiagnostic)}\nChecked 12 files in 40ms. Found 1 error.`;
49
+ expect(parseBiomeReport(output).diagnostics).toHaveLength(1);
50
+ });
51
+
52
+ test("keeps a diagnostic whose message contains braces and quotes", () => {
53
+ const message = 'Replace {" a "} with { " a " } — the `{}` object syntax is not allowed';
54
+ const report = parseBiomeReport(reportOf([{ severity: "error", message }]));
55
+ expect(report.diagnostics?.[0]?.message).toBe(message);
56
+ });
57
+
58
+ test("keeps a diagnostic whose message ends in an escaped backslash before the closing quote", () => {
59
+ const message = "path separator is \\\\";
60
+ const report = parseBiomeReport(reportOf([{ severity: "warning", message }]));
61
+ expect(report.diagnostics?.[0]?.message).toBe(message);
62
+ expect(report.diagnostics).toHaveLength(1);
63
+ });
64
+
65
+ test("accepts a report carrying only a summary", () => {
66
+ expect(parseBiomeReport('{"summary":{"errors":0,"warnings":0}}').summary?.errors).toBe(0);
67
+ });
68
+
69
+ test("throws the output when it holds no JSON at all", () => {
70
+ expect(() => parseBiomeReport("biome: command not found\n")).toThrow("biome: command not found");
71
+ });
72
+
73
+ test("throws the output when the only brace never closes", () => {
74
+ expect(() => parseBiomeReport('internal error: {"diagnostics":[')).toThrow("internal error");
75
+ });
76
+
77
+ test("throws a named error for empty output", () => {
78
+ expect(() => parseBiomeReport(" \n")).toThrow("No Biome JSON output");
79
+ });
80
+ });
package/linter.ts CHANGED
@@ -3,14 +3,14 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import chalk from "chalk";
5
5
 
6
- type BiomeSeverity = "error" | "warning" | "information" | "hint";
6
+ export type BiomeSeverity = "error" | "warning" | "information" | "hint";
7
7
 
8
- interface BiomePosition {
8
+ export interface BiomePosition {
9
9
  line: number;
10
10
  column: number;
11
11
  }
12
12
 
13
- interface BiomeDiagnostic {
13
+ export interface BiomeDiagnostic {
14
14
  severity: BiomeSeverity;
15
15
  message: string;
16
16
  category?: string;
@@ -21,7 +21,7 @@ interface BiomeDiagnostic {
21
21
  };
22
22
  }
23
23
 
24
- interface BiomeReport {
24
+ export interface BiomeReport {
25
25
  summary?: {
26
26
  changed?: number;
27
27
  errors?: number;
@@ -58,19 +58,81 @@ interface LintResponse {
58
58
  warnings: LintMessage[];
59
59
  }
60
60
 
61
+ /**
62
+ * Extracts the `--reporter=json` report from Biome's output.
63
+ *
64
+ * Biome mixes configuration and IO diagnostics into the same stream as the report, so the output is not
65
+ * always JSON alone. Slicing from the first `{` to the last `}` mis-parses as soon as that leading text
66
+ * carries a brace of its own, which is not hypothetical: a config error printed ahead of the report made
67
+ * one unchanged command alternate between 2 and 10 diagnostics. So every `{` is tried as a start, its
68
+ * match is found by depth while string literals are skipped, and the first candidate that both parses and
69
+ * carries a report field wins — a brace in prose parses as nothing, and `{"a":1}` in prose is not a report.
70
+ */
71
+ export const parseBiomeReport = (output: string): BiomeReport => {
72
+ for (let start = output.indexOf("{"); start !== -1; start = output.indexOf("{", start + 1)) {
73
+ const end = objectEndAt(output, start);
74
+ if (end === -1) break;
75
+ const report = asBiomeReport(output.slice(start, end + 1));
76
+ if (report) return report;
77
+ }
78
+ throw new Error(output.trim() || "No Biome JSON output");
79
+ };
80
+
81
+ const objectEndAt = (output: string, start: number): number => {
82
+ let depth = 0;
83
+ let inString = false;
84
+ let escaped = false;
85
+ for (let idx = start; idx < output.length; idx += 1) {
86
+ const char = output[idx];
87
+ if (inString) {
88
+ if (escaped) escaped = false;
89
+ else if (char === "\\") escaped = true;
90
+ else if (char === '"') inString = false;
91
+ continue;
92
+ }
93
+ if (char === '"') inString = true;
94
+ else if (char === "{") depth += 1;
95
+ else if (char === "}" && --depth === 0) return idx;
96
+ }
97
+ return -1;
98
+ };
99
+
100
+ const asBiomeReport = (candidate: string): BiomeReport | null => {
101
+ try {
102
+ const parsed = JSON.parse(candidate) as unknown;
103
+ if (!parsed || typeof parsed !== "object") return null;
104
+ if (!("diagnostics" in parsed) && !("summary" in parsed)) return null;
105
+ return parsed as BiomeReport;
106
+ } catch {
107
+ return null;
108
+ }
109
+ };
110
+
111
+ /** Biome reads `biome.json` first and `biome.jsonc` second; only the latter may carry comments. */
112
+ const BIOME_CONFIG_FILES = ["biome.json", "biome.jsonc"] as const;
113
+
61
114
  export class Linter {
62
115
  lintRoot: string;
116
+ configPath: string;
63
117
  #biomeBin: string;
64
118
 
65
119
  constructor(cwdPath: string) {
66
120
  this.lintRoot = this.#findBiomeRootPath(cwdPath);
121
+ this.configPath = Linter.#configPathIn(this.lintRoot) ?? path.join(this.lintRoot, "biome.json");
67
122
  const localBiomeBin = path.join(this.lintRoot, "node_modules/.bin/biome");
68
123
  this.#biomeBin = existsSync(localBiomeBin) ? localBiomeBin : "biome";
69
124
  }
70
125
 
126
+ static #configPathIn(dir: string): string | null {
127
+ for (const fileName of BIOME_CONFIG_FILES) {
128
+ const configPath = path.join(dir, fileName);
129
+ if (existsSync(configPath)) return configPath;
130
+ }
131
+ return null;
132
+ }
133
+
71
134
  #findBiomeRootPath(dir: string): string {
72
- const configPath = path.join(dir, "biome.json");
73
- if (existsSync(configPath)) return dir;
135
+ if (Linter.#configPathIn(dir)) return dir;
74
136
  const parentDir = path.dirname(dir);
75
137
  if (parentDir === dir) throw new Error(`biome.json not found from ${dir}`);
76
138
  return this.#findBiomeRootPath(parentDir);
@@ -111,14 +173,6 @@ export class Linter {
111
173
  });
112
174
  }
113
175
 
114
- #parseBiomeReport(output: string): BiomeReport {
115
- const jsonStart = output.indexOf("{");
116
- const jsonEnd = output.lastIndexOf("}");
117
- if (jsonStart === -1 || jsonEnd === -1 || jsonEnd < jsonStart)
118
- throw new Error(output.trim() || "No Biome JSON output");
119
- return JSON.parse(output.slice(jsonStart, jsonEnd + 1)) as BiomeReport;
120
- }
121
-
122
176
  #diagnosticFilePath(diagnostic: BiomeDiagnostic, fallbackFilePath: string) {
123
177
  const diagnosticPath = diagnostic.location?.path;
124
178
  if (!diagnosticPath) return fallbackFilePath;
@@ -193,10 +247,10 @@ export class Linter {
193
247
  "--max-diagnostics=none",
194
248
  "--no-errors-on-unmatched",
195
249
  "--config-path",
196
- path.join(this.lintRoot, "biome.json"),
250
+ this.configPath,
197
251
  this.#toBiomePath(filePath),
198
252
  ]);
199
- const report = this.#parseBiomeReport(stdout || stderr);
253
+ const report = parseBiomeReport(stdout || stderr);
200
254
  const results = this.#toLintResults(report, filePath);
201
255
  const { errors, warnings } = this.#splitMessages(results);
202
256
  const output = write && existsSync(filePath) ? readFileSync(filePath, "utf8") : undefined;
@@ -390,14 +444,7 @@ export class Linter {
390
444
 
391
445
  const source = readFileSync(resolvedFilePath, "utf8");
392
446
  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
- ],
447
+ ["check", "--write", "--config-path", this.configPath, "--stdin-file-path", this.#toBiomePath(resolvedFilePath)],
401
448
  source,
402
449
  );
403
450
  const lintResult = await this.lintFile(resolvedFilePath);
@@ -409,10 +456,37 @@ export class Linter {
409
456
  * @param filePath - Path to the file
410
457
  * @returns Biome configuration object
411
458
  */
459
+ /**
460
+ * Applies Biome's safe fixes to many files in one process.
461
+ *
462
+ * One spawn, not one per file: Biome's startup dominates a small file, so the per-file path turns a
463
+ * fifteen-file scaffold into three seconds of process launches. Paths that do not exist are dropped
464
+ * rather than thrown on, because the caller is usually reporting what a template just wrote and a
465
+ * template is allowed to decline a file.
466
+ */
467
+ async fixFiles(filePaths: string[]): Promise<{ fixed: string[] }> {
468
+ const resolved = filePaths.map((filePath) => this.#resolveFilePath(filePath)).filter(existsSync);
469
+ if (resolved.length === 0) return { fixed: [] };
470
+ const before = new Map(resolved.map((filePath) => [filePath, readFileSync(filePath, "utf8")]));
471
+ await this.#runBiome([
472
+ "check",
473
+ "--write",
474
+ "--reporter=json",
475
+ "--max-diagnostics=none",
476
+ "--no-errors-on-unmatched",
477
+ "--config-path",
478
+ this.configPath,
479
+ ...resolved.map((filePath) => this.#toBiomePath(filePath)),
480
+ ]);
481
+ return {
482
+ fixed: resolved.filter((filePath) => readFileSync(filePath, "utf8") !== before.get(filePath)),
483
+ };
484
+ }
485
+
412
486
  async getConfigForFile(filePath: string): Promise<unknown> {
413
487
  const resolvedFilePath = this.#resolveFilePath(filePath);
414
488
  if (!existsSync(resolvedFilePath)) throw new Error(`File not found: ${filePath}`);
415
- return JSON.parse(readFileSync(path.join(this.lintRoot, "biome.json"), "utf8")) as unknown;
489
+ return JSON.parse(readFileSync(this.configPath, "utf8")) as unknown;
416
490
  }
417
491
 
418
492
  /**