@akanjs/devkit 3.2.0-rc.0 → 3.2.0-rc.2

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 (38) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/akanContext.ts +1 -3
  3. package/akanMcpContract.ts +4 -3
  4. package/artifact/storeBootstrap.ts +3 -2
  5. package/codeAgent/skills/akan-scaffold/SKILL.md +2 -1
  6. package/commandDecorators/command.ts +35 -18
  7. package/commandDecorators/commandDecorators.test.ts +75 -3
  8. package/commandDecorators/targetMeta.ts +2 -0
  9. package/constantFieldNames.test.ts +34 -0
  10. package/constantFieldNames.ts +18 -0
  11. package/createTunnel.test.ts +12 -2
  12. package/frontendBuild/buildRouteClient.test.ts +43 -0
  13. package/frontendBuild/clientEntryDiscovery.test.ts +22 -0
  14. package/frontendBuild/clientEntryDiscovery.ts +28 -3
  15. package/frontendBuild/csrArtifactBuilder.test.ts +48 -0
  16. package/frontendBuild/csrArtifactBuilder.ts +9 -3
  17. package/frontendBuild/csrDevBundler.ts +12 -2
  18. package/frontendBuild/csrDevModuleCompiler.test.ts +97 -0
  19. package/frontendBuild/csrDevModuleCompiler.ts +37 -8
  20. package/frontendBuild/csrDevPatcher.test.ts +89 -2
  21. package/frontendBuild/csrDevPatcher.ts +47 -7
  22. package/frontendBuild/csrDevTypes.ts +16 -0
  23. package/frontendBuild/cssCompiler.test.ts +63 -1
  24. package/frontendBuild/devBarrelImports.ts +132 -0
  25. package/frontendBuild/devRegistryBundler.ts +19 -4
  26. package/frontendBuild/mcpAppViewBuilder.test.ts +67 -0
  27. package/frontendBuild/ssrDevBundler.ts +13 -2
  28. package/package.json +3 -3
  29. package/preview/PreviewChrome.ts +3 -3
  30. package/preview/PreviewView.ts +26 -8
  31. package/preview/previewView.test.ts +87 -3
  32. package/transforms/barrelAnalyzer.ts +46 -1
  33. package/transforms/barrelImportsPlugin.ts +61 -45
  34. package/transforms/transforms.test.ts +67 -1
  35. package/workflow/artifacts.ts +17 -0
  36. package/workflow/plan.ts +54 -14
  37. package/workspaceGraph.test.ts +116 -3
  38. package/workspaceGraph.ts +6 -3
package/CHANGELOG.md CHANGED
@@ -34,6 +34,32 @@
34
34
  - **`pkgs/akanjs/ARCHITECTURE.md`** pairs a hand-kept walk through the processes and a request with a generated part between markers. `akan sync-architecture` regenerates that part, and a devkit test fails when it is stale, when a channel is gone, when two facets load values from each other, when a message lacks a recognised sender or handler outside a short list whose other end lies beyond the framework source, or when the hand-kept part names a class the source no longer has.
35
35
  - **`AkanIpcMessage` no longer lists `backend-ready`.** The gateway sends it to the dev host, which reads it as a `BuilderMessage`, where it stays; no gateway or child ever handled it.
36
36
 
37
+ - c723713: The Akan Graph keys a guard function by the file that declares it, locates more of a workspace's guards, and stops counting things that are not guards.
38
+
39
+ A guard function a route or layout imports is keyed by its file, as one the page declares already was: `guard:libs/shared:webkit/signedIn.ts#signedIn`. A `canEdit` in `webkit/` and one in `common/` are two nodes, each at its own declaration, and an edit to either marks only that one changed in the Change Map. A guard called through a namespace import (`auth.signedIn()`), or imported under a name a file re-exports (`export { signedIn as hasSession } from "./auth"`), reaches its declaration. A function the guard declares, or a parameter it binds, shadows an import of the same name only inside its own scope, so `[1].forEach((signedIn) => …)` no longer hides a real `signedIn()` beside it. A guard declared after another declarator (`export const ready = true, signedIn = …`) is found, and an overloaded guard's digest covers every signature. Only a file whose text declares or re-exports the name is parsed, so a guard that calls `usePage()` from the generated client no longer parses every page that names it.
40
+
41
+ A function is a guard factory only when it returns a guard, a class that implements `Guard` (also one handed to a wrapper, `withName(class implements Guard {…}, field)`) or a call to a factory, so `const guardCount = countGuards()` is no longer a guard. An import from a package outside the workspace (`@vendor/auth`) resolves to no guard: it no longer links a same-name workspace guard, and a vendor's `Public` no longer reads as the framework's. A factory imported from akanjs or a package makes no guard. A lib's `export *` chain, walked from the path the import names, is followed only when it reaches exactly one workspace lib declaring the name, none of its `export *` names a package, and the lib does not declare, destructure or export the name itself; otherwise the guard stays `guard:<lib>:<Name>` without a location, so a vendor shim no longer links another lib's guard. A class extending a base that is not a known guard is no guard, even when a lib declares a guard of that name.
42
+
43
+ More guards carry their location: a name a lib re-exports under another name (`export { Admin as AdminGuard }`) reaches the class, and one it re-exports from akanjs is `guard::Public`; `srvkit.SSO.Kakao` reads through the namespace to the lib's map; a map entry a factory makes (`SSO = { Kakao: ssoGuardOf("kakao") }`) is the guard `SSO.Kakao`; a local wrapper of an imported factory, a factory called through a namespace (`srvkit.resourceGuard("pin")`) and a class that extends a guard (`class DemoAdmin extends Admin`) and a factory a lib re-exports under another name (`export { resourceGuard as rg }`) make guards; a guard made in place, `guards: [canEdit("memo")]`, is a node at the call, keyed by its member, callee and ordinal so an edit to its arguments, or to another member's guards, keeps the id. Of two same-name guards in one owner, an import reaches the one its path names. A wrapper's digest covers every factory it can return, and an edit to a factory in another file marks the guards it makes changed instead of listing the file as unmapped.
44
+
45
+ In the Change Map, a declaration whose id moves between `kind:owner:Name` and `kind:owner:<file>#Name` at the same file is one node, so a second same-name guard shows as one addition, not the first one removed and re-added.
46
+
47
+ `get_graph` past the node limit names owners by the nodes it would answer with, changed pages included, so 650 changed routes no longer hide behind one changed lib method. A guard on `const route = page()…; export default route` is no longer read: `RouteSourceValidator` rejects that shape.
48
+
49
+ Ids and details that change for a saved graph or a Change Map base:
50
+
51
+ - An imported guard function's id names its file: `guard:libs/shared:signedIn` is now `guard:libs/shared:webkit/signedIn.ts#signedIn`.
52
+ - A guard name two files of one owner declare is keyed by file, `guard:apps/demo:srvkit/admin.ts#Admin`.
53
+ - A guard made in place is `guard:<owner>:<file>#<model>.<key>:<callee>@<n>`, the `n`th in-place call of that callee in that slice or endpoint key (`…/memo.signal.ts#memo.shareMemo:resourceGuard@1`). Its label, an endpoint's `detail` and the editor hover name the call: `resourceGuard("memo")` where `detail` read `resourceGuard`, or `allow(…)` when the arguments run past 32 characters or a line. A guard read through a namespace's map reads `srvkit.SSO.Kakao` where it read `Kakao`.
54
+ - A map entry a factory makes is `guard:libs/shared:SSO.Kakao` where it was `guard::Kakao` or `guard:<lib>:Kakao`, and a local map's `Roles.Owner` is `guard:apps/demo:Roles.Owner` where it linked `guard:apps/demo:Owner`.
55
+ - A guard a lib re-exports, under its own name or another, or from akanjs, takes the declaring owner's id (`guard:libs/util:Admin`, `guard::Public`) where it took the re-exporting lib's.
56
+ - A guard imported from an unknown package draws no edge.
57
+ - A name the old "sole visible lib" fallback linked through a lib that does not declare it (`guard:libs/billing:Admin` behind a vendor `export *`) now reads `guard:<lib>:<Name>` without a location, unless the lib's `export *` chain reaches exactly one workspace lib declaring it.
58
+ - A class extending a base that is not a known guard (`class Thing extends Base` with an ordinary `Base`) no longer has a guard node; an endpoint naming it in `guards` links an unlocated `guard:<owner>:Thing`.
59
+
60
+ - **`AkanGraphNode.sources`** in `akanjs/common`: the files besides `at.file` that a node's digest reads, set only with digests. The Change Map uses it to map those files and leaves it out of its answer.
61
+ - **The fact reader** in `@akanjs/language`: `GuardFactoryFact.via` is a list and gains `direct`; `GuardCallFact` gains `kind` (`call` or `extends`) and `scope`; `FileFacts` gains `guardLabels`, an in-place guard's name to its call. A slice's or endpoint's guard made in place is read as `<model>.<key>:<callee>@<n>`, and every in-place call is a `GuardCallFact`.
62
+
37
63
  - 0351ad4: The Akan Graph keys each guard by the app or lib that declares it, `guard:libs/shared:Admin`, and follows the import that names it there, so `Admin as AdminGuard` reaches `Admin`, `srvkit.Admin` reaches the lib's class, and two libs' `Admin` stay two nodes. A guard read out of an object map, `SSO.Kakao`, resolves through the imports of the file that declares the map. A name a lib only re-exports reaches the class it re-exports. A guard a factory makes is a guard too, whether the factory sits in the same file, wraps another factory there, or is imported from another lib. A guard node carries its source location, `doc` (the comment above its declaration that says what it checks) and, in `detail`, its `scope` (`account` or `resource`). A factory-made guard takes the factory's scope, and takes its doc when no guard of that factory has its own. With digests, an edit to a guard or to the factory that makes it marks the guard changed in the Change Map. Every guard a workspace declares is a node, referenced or not. `Public` and `None` come from `akanjs`, carry no location and say what they do. Studio shows a node's `doc` in its tooltip.
38
64
 
39
65
  Ids and details that change for a saved graph or a Change Map base:
@@ -128,6 +154,7 @@
128
154
  - **`@akanjs/devkit`**: `applyTemplate` takes `dryRun` to list the files a template would write without writing them, and `WorkspaceExecutor.commit` takes an `identity` to commit under.
129
155
 
130
156
  - 1870fcd: `akan verify` adds a `fix:` line to a module file's two-parent import that names the layer barrel to import from instead, such as `import from "@apps/<app>/common"`. The lint message itself now points at the layer barrel too.
157
+ - 76ab8d4: `akan doctor` exits with code 1 when its report says `"status": "failed"`, in text or JSON, with or without `--strict`, and for `--ios`, so the `agent:doctor` script (`akan doctor --strict --format json`) or a CI step fails on it. Before, it always exited 0. The full report still prints on stdout. A warning never changes the exit code: `--strict` still only decides which conventions count as errors, today a missing module abstract. Doctor's `dictionary-label-missing` now reads a constant's fields from its syntax, so a field that appears only in a comment or a string no longer asks for a label. A new `--sample` workspace no longer warns about `task`'s `priority`, `tags`, `assignee` and `completedAt` or `workHistory`'s `actor` and `previousStatus`, which its constants suggest only in comments.
131
158
  - 245d318: A font declared on a root layout now gets its metric-adjusted fallback faces: `@font-face` rules named `"<font> fallback"` that draw a local system font with `size-adjust`, `ascent-override` and `descent-override` matched to the web font. The variable `--font-<name>` already named that family, but the build never wrote it, because fontaine's `readMetrics` takes a URL and returned nothing for the file path it was given. While a web font loaded (`font-display: swap`), text drew in the browser's default font, usually a serif, and moved when the web font arrived. The metrics are read from the source font, not the subset, so a subset without Latin letters (`subsets: ["ks-x-1001"]`, or only `subsetText`) still sizes the fallback correctly. A font whose metrics cannot be read keeps its face and gets no fallback, as before. Set `adjustFontFallback: false` on a font to keep the old output. Cached font output from `akan start` is regenerated once.
132
159
  - 6f6cc2a: A fresh clone of a workspace now builds and starts after `bun install` alone. The env files that hold secrets stay gitignored, so a clone has none of them: before, `akan typecheck`, `akan build` and `akan verify` failed with `Cannot find module './env.server.local'`, and with no `.env` the CLI picked the `debug` environment, so `akan build` stopped asking for `AKAN_PUBLIC_REPO_NAME` and `AKAN_PUBLIC_SERVE_DOMAIN`.
133
160
 
@@ -177,7 +204,12 @@
177
204
  - **The CSR build and its MCP App views** rewrite `akanjs/ui` imports to the components a file names, as the SSR route builds already did, so a page that imports `Link` no longer bundles every component.
178
205
  - **Animations use `@react-spring/web`** instead of the `react-spring` umbrella package, which also installed the native, three.js, konva and zdog renderers.
179
206
 
207
+ - 686e0b8: `akan preview` and the code agent's page preview bound a page only once the browser is up. Before, a process's first page shared its `timeoutMs` with starting the browser. On a cold machine where Chrome took longer than that to start, the first page failed as `Timed out loading …; the dev server may be rebuilding or stuck.`
208
+
209
+ Starting the browser now has its own bound of 120 s, `startTimeoutMs` on `PreviewView.probe`. Running out of it reads `Timed out starting the <engine> browser after 120 s.`, where the engine is `chrome` or `webkit`. A browser that misses its bound may still come up late, so later pages in that process try again, but each waits only its own `timeoutMs` for the browser. Once the browser answers, the full bound applies again. So a browser that never starts holds the first page for 120 s, where before it held it for `timeoutMs` (60 s for `akan preview`, 30 s for the code agent's check).
210
+
180
211
  - eefd9cb: `akan preview <app> /_akan/studio` opens Studio instead of a 404. Preview added the default locale to every path, and the server routes `/_akan/*` without one, so `/en/_akan/studio` matched no page. A path under `/_akan`, `/__csr`, `/__rsc`, `/.well-known` or the app's API prefix, a file the app's `public/` holds, and the server's own `/favicon.ico`, `/robots.txt`, `sitemap.xml` and `manifest.webmanifest` now open as written: no locale, and no CSR boot query under `--csr`. Every other path, including one that already starts with a locale or ends in a dotted segment such as `/release/1.2.3`, is a page and gets the default locale as before. The `akan-debug` skill and the `akan preview` reference say so.
212
+ - b101fcf: `akan remove-application`, `remove-library`, `remove-package`, `remove-module` and `remove-scalar` no longer delete a target they picked themselves. Left out, or named but not in the workspace, the target is refused in a terminal or not, with the choices and the line to rerun, such as `` Missing app: this command deletes files and never picks its target, so rerun it as `akan remove-application <app>` (choices: pokedex). `` Before, `akan remove-application` in a one-app workspace removed `apps/<app>` without asking, and without a terminal `akan remove-scalar <name>` removed the scalar from the only app or lib. A terminal is no longer offered a select for these commands. Other commands still take the only app, or the only app or lib without a terminal, as before. A command declares this with `destructive: true` on its target.
181
213
  - 4fcf44f: A new `rename-key` workflow renames a slice key or a Filter query key everywhere it is spelled. That covers the key itself, every name derived from it (`initMemoInPublic`, `memoListInPublic`, `queryByName`) in the app and every lib, and its module dictionary entry. It is the same rename the editor extension's F2 runs, so agents get it through `plan_workflow` / `apply_workflow` and the CLI through `akan workflow plan rename-key --app <app> --module <module> --slice <key> --to <newKey>` (or `--query <key>`). The plan lists each file it will change. A use that could also be another model's is left alone, and `akan verify` then reports it.
182
214
  - 9a9e1fa: A server page from `akan build` now preloads its client modules from `<head>`, so a cold load stops waiting on chains of imports.
183
215
 
@@ -259,8 +291,13 @@
259
291
  - **Bootstrap.** Each route's `"use client"` entry, every generated root layout and the SSR dev registry start with `import "@apps/<app>/lib/useClient"` and `import "@apps/<app>/lib/st"`, which load the client runtime and the store. They used to import `@apps/<app>/client` bare, and that bundled the whole barrel.
260
292
  - **Narrowed barrel imports.** A named import from an app or lib `client` barrel is still narrowed to the files that define the names. This includes `import { st } from "@apps/<app>/client"` in a component and the generated `import { RootStore as <lib> } from "@libs/<lib>/client"` in `lib/st.ts`.
261
293
  - What the rewrite keeps of the barrel is now the files that define its `usePage` and `st`, normally `lib/useClient` and `lib/st`. It used to keep a bare import of the barrel itself, which put every module's UI of the app and of each merged lib on every route.
262
- - A barrel that defines neither name keeps its bare import, as before.
294
+ - A barrel that lacks either name keeps its bare import, as before. One that defines only one of them is not the generated shape, so what else it runs on import is unknown.
263
295
  - **Namespace re-exports.** A name the barrel re-exports as a namespace (`export * as cnst`, `export * as store`) is now narrowed too. `import { cnst } from "@libs/<lib>/client"` becomes `import * as cnst from "@libs/<lib>/lib/cnst"`, where it used to load the whole barrel.
296
+ - **`akan start`.** The dev module registry narrows app and lib barrels the same way, so a page loads in dev the module UI it loads in production. The narrowed imports are written as relative paths, so a save that adds a barrel import still updates the page without a full rebuild. `akanjs` barrels stay whole in dev: in an installed workspace their files are npm modules, and a save that reached a new one would rebuild the whole registry.
297
+ - On `apps/minimal`, the registry behind `/en/explore`, `/en/memo` and `/en/lab/data` holds 505 modules (2.5MB app, 1.2MB vendor), down from 949 (2.6MB app, 6.0MB vendor).
298
+ - Moving an export to another file of a barrel compiles again the importers whose names now point at that file, and only those. This works during `akan start` and on the next start after a move made while it was stopped.
299
+ - Client entry discovery under `akan start` reads a barrel again when one of its files changes. An export moved into a `"use client"` file is found without a restart.
300
+ - **CSR shell and MCP App views.** The single-file CSR build that native apps load, and each MCP App view, narrow app and lib barrels too; they used to narrow only `akanjs/ui`. On `apps/minimal` the CSR shell is 1.58MB raw / 411.9K br, down from 3.51MB / 828.8K, and its MCP App views are 20-29% smaller.
264
301
  - **Discovery and Tailwind.** Client entry discovery and Tailwind's candidate scan follow the same imports. A lib `"use client"` component is no longer a client entry of every route, and a module UI file that no route imports is no longer scanned.
265
302
  - **Size.** Production build of `apps/minimal`:
266
303
  - A route that renders only `Link` ships 53.1K raw / 17.2K br of route code, down from 226.6K / 67.1K.
@@ -284,11 +321,13 @@
284
321
  - The rule's own rewrite is removed. Under `--unsafe` it renamed a `#field` where it was called but not where it was declared.
285
322
  - **A raw `Error` in an `srvkit/` adapter names its fix.** `akan verify` prints the `Err` import path from that file, and where the key goes: the app's or lib's own service dictionary, `lib/_<name>/<name>.dictionary.ts`, as `<name>.error.<key>`.
286
323
 
324
+ - d2b2291: A failed workflow no longer reports success to its caller. `akan workflow plan` exits 1 when the plan has an error, `akan workflow apply` when the apply or its `--dry-run` fails, and `akan workflow validate` when its status is `failed` (which includes `passed-with-baseline-blockers`); all three exited 0 before. A dry run of a plan with an error, such as `add-field --type Number`, reports `failed` and lists no changed or generated files, where it used to list the plan's edits under "Automatically Modified". Over MCP, `plan_workflow` names `apply_workflow` in `next` and `approval.canApplyWith` only for a plan without an error, and `apply_workflow` names `run_validation` only when the apply did not fail; a refused plan also drops its "apply this plan" and "validate the apply report" recommendations. The workflows that write into an existing module (`add-field`, `add-enum-field`, `add-mutation`, `add-slice`, `create-ui`, `rename-key`) now refuse a service module, or any folder without its `<module>.constant.ts`, at plan time and again at apply. The `workflow-module-not-database` error lists the target's database modules and points at `create-module`, and for a field at `create-scalar`. Before, `add-field` on a service module passed its plan and dry run and failed only at apply, and `create-ui` wrote a misnamed `_<service>.View.tsx` into it. The `workflow-module-missing` error now lists only database modules too, says a name that matches a service module (with or without its `_`) is one instead of asking "Did you mean \_<service>?", and points at another app or library only when it holds a database module of that name.
287
325
  - adcdf3b: `akan workflow plan` and the `plan_workflow` MCP tool refuse a module the target app or library lacks, for every workflow that writes into an existing module (`add-field`, `add-enum-field`, `add-mutation`, `add-slice`, `create-ui`, `rename-key`). The `workflow-module-missing` error names the module, lists the modules the target has, and points at the module whose name differs only in case or by a leading `_`, at a scalar of that name, or at the app or library that holds it; only when none exists does it point at `create-module`. Before, the plan passed and its dry run reported `passed`. `add-mutation` used to plan a missing module with a warning so that a `create-module` plan could precede it; it now refuses too, so plan it after applying `create-module`. Every workflow now refuses an unknown `app` at plan time, listing the workspace's apps and libraries. `apply` and `--dry-run` check the target again before anything else, so a plan written earlier for a module that is gone fails instead of passing. `plan_workflow` now inspects the MCP server's workspace rather than the process directory.
288
326
  - Updated dependencies [bc792ab]
289
327
  - Updated dependencies [61fbd7d]
290
328
  - Updated dependencies [b3f85fa]
291
329
  - Updated dependencies [a707354]
330
+ - Updated dependencies [c723713]
292
331
  - Updated dependencies [0351ad4]
293
332
  - Updated dependencies [ba417c6]
294
333
  - Updated dependencies [bb40785]
@@ -343,6 +382,7 @@
343
382
  - Updated dependencies [df32c46]
344
383
  - Updated dependencies [5f809e8]
345
384
  - Updated dependencies [206bb69]
385
+ - Updated dependencies [6291fec]
346
386
  - Updated dependencies [0c480c6]
347
387
  - Updated dependencies [d9eaacc]
348
388
  - akanjs@3.2.0
package/akanContext.ts CHANGED
@@ -2,6 +2,7 @@ import { readdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { capitalize, isRecord, parseRouteModuleKey } from "akanjs/common";
4
4
  import { extractBlockVersion, readDevkitVersion } from "./agentsIndex";
5
+ import { constantFieldNames } from "./constantFieldNames";
5
6
  import { AppExecutor, LibExecutor, type SysExecutor, type WorkspaceExecutor } from "./executors";
6
7
  import { FileSys } from "./fileSys";
7
8
  import { collectRecipeSources, findInlineRecipeDuplicates, scanRecipes } from "./recipeScanner";
@@ -337,9 +338,6 @@ const moduleShapeFiles = (module: AkanModuleContext) => {
337
338
  ];
338
339
  };
339
340
 
340
- const constantFieldNames = (content: string) =>
341
- [...content.matchAll(/\b([A-Za-z_$][\w$]*)\s*:\s*field\(/g)].map((match) => match[1]).filter(Boolean);
342
-
343
341
  const safeReadDir = async (dirPath: string) =>
344
342
  (await readdir(dirPath, { withFileTypes: true }).catch(() => [])).sort((a, b) => a.name.localeCompare(b.name));
345
343
 
@@ -199,7 +199,7 @@ export const defaultWorkflowPlanPath = (workflow: string, inputs: WorkflowPlanIn
199
199
  export const applyFirstPolicy = {
200
200
  mode: "apply-first",
201
201
  directSourceEdits: "fallback-only",
202
- applyRequiredWhen: ["plan_workflow returns planPath", "plan_workflow returns next.tool=apply_workflow"],
202
+ applyRequiredWhen: ["plan_workflow returns next.tool=apply_workflow"],
203
203
  approvalMeaning:
204
204
  "requiresApproval is an agent/user review signal before apply_workflow mutates files; it is not a separate MCP permission gate.",
205
205
  validationRequiredAfterApply: ["validationTarget", "applyReportPath"],
@@ -371,7 +371,7 @@ export const planMcpTools: McpToolDefinition[] = [
371
371
  {
372
372
  name: "plan_workflow",
373
373
  description:
374
- "Create a read-only workflow plan after context inspection and return planPath plus next.tool=apply_workflow.",
374
+ "Create a read-only workflow plan after context inspection and return planPath, plus next.tool=apply_workflow unless a diagnostic is an error.",
375
375
  inputSchema: {
376
376
  type: "object",
377
377
  properties: { workflow: stringProperty, inputs: objectProperty, out: stringProperty },
@@ -383,7 +383,8 @@ export const planMcpTools: McpToolDefinition[] = [
383
383
  export const applyMcpTools: McpToolDefinition[] = [
384
384
  {
385
385
  name: "apply_workflow",
386
- description: "Apply a stored workflow plan before direct edits and return validationTarget for run_validation.",
386
+ description:
387
+ "Apply a stored workflow plan before direct edits and return validationTarget, plus next.tool=run_validation unless the apply failed.",
387
388
  inputSchema: {
388
389
  type: "object",
389
390
  properties: { planPath: stringProperty, dryRun: booleanProperty },
@@ -14,8 +14,9 @@ export class StoreBootstrap {
14
14
  if (!/^@(?:apps|libs)\/[^/]+\/client$/.test(specifier)) return null;
15
15
  const runtime = exports.get("usePage");
16
16
  const store = exports.get("st");
17
- if (!runtime || runtime.namespace || !store || store.namespace) return StoreBootstrap.#lines([specifier]);
18
- return StoreBootstrap.#lines([...new Set([runtime.subpath, store.subpath])]);
17
+ //? A barrel with only one of the two is not the generated shape, so what else it runs on import is unknown.
18
+ if (!runtime || runtime.namespace || !store || store.namespace) return [specifier];
19
+ return [...new Set([runtime.subpath, store.subpath])];
19
20
  }
20
21
 
21
22
  static #lines(specifiers: string[]): string[] {
@@ -37,7 +37,8 @@ Then pick the workflow that matches the intent:
37
37
 
38
38
  ## The chain
39
39
 
40
- Each result names the next call in `next.tool`. Follow it; do not improvise.
40
+ Each result names the next call in `next.tool`. Follow it; do not improvise. A result with an error diagnostic, or
41
+ an apply that failed, names none: do what the diagnostic says, then plan again.
41
42
 
42
43
  1. `plan_workflow({ workflow: "create-module", inputs: { app: "<app>", module: "comment" } })` — read-only.
43
44
  Returns `planPath` and `next.tool=apply_workflow`. (`list_workflows` / `explain_workflow` give the names
@@ -138,16 +138,22 @@ const assertEnumChoice = (argMeta: ArgMeta, value: unknown) => {
138
138
  };
139
139
 
140
140
  // On a piped stdin a prompt takes the pipe as its answer and echoes it: a piped secret printed as the secret's name.
141
- export const assertPromptable = (
142
- problem: string,
143
- { choices = [], remedy = "pass it on the command line" }: { choices?: readonly unknown[]; remedy?: string } = {},
144
- ) => {
141
+ export const assertPromptable = (problem: string, refusal: Refusal = {}) => {
145
142
  if (process.stdin.isTTY) return;
146
- throw new Error(
147
- `${problem}: stdin is not a terminal to prompt on, so ${remedy}${choices.length ? ` (choices: ${choices.join(", ")})` : ""}.`,
148
- );
143
+ throw refusalOf(problem, "stdin is not a terminal to prompt on", refusal);
149
144
  };
150
145
 
146
+ interface Refusal {
147
+ choices?: readonly unknown[];
148
+ remedy?: string;
149
+ }
150
+
151
+ const refusalOf = (
152
+ problem: string,
153
+ reason: string,
154
+ { choices = [], remedy = "pass it on the command line" }: Refusal,
155
+ ) => new Error(`${problem}: ${reason}, so ${remedy}${choices.length ? ` (choices: ${choices.join(", ")})` : ""}.`);
156
+
151
157
  const inputMessageOf = ({ name, argsOption: { desc, example, ask } }: ArgMeta) =>
152
158
  ask ? `${ask}: ` : desc ? `${desc}: ` : `Enter the ${name} value${example ? ` (example: ${example})` : ""}: `;
153
159
 
@@ -293,11 +299,18 @@ export const getInternalArgumentValue = async (
293
299
  value: string | undefined,
294
300
  workspace: WorkspaceExecutor,
295
301
  rerun?: RerunOf,
302
+ { destructive = false }: { destructive?: boolean } = {},
296
303
  ) => {
297
304
  if (argMeta.type === "Workspace") return workspace;
298
305
  const sysType = argMeta.type.toLowerCase();
299
306
  const [appNames, libNames, pkgNames] = await workspace.getExecs();
300
307
  const problemOf = (kind: string) => (value ? `Unknown ${kind} '${value}'` : `Missing ${kind}`);
308
+ //? Refused in a terminal too: a select deletes on a stray Enter, its cursor resting on the first choice.
309
+ const assertAskable = (kind: string, choices: readonly unknown[], placeholder: string) => {
310
+ const refusal = { choices, ...rerunRemedyOf(rerun, placeholder) };
311
+ if (destructive) throw refusalOf(problemOf(kind), "this command deletes files and never picks its target", refusal);
312
+ assertPromptable(problemOf(kind), refusal);
313
+ };
301
314
  if (sysType === "sys" || sysType === "exec") {
302
315
  const pkgChoices = sysType === "exec" ? pkgNames : [];
303
316
  const execOf = (name: string) => {
@@ -308,14 +321,14 @@ export const getInternalArgumentValue = async (
308
321
  };
309
322
  const found = value ? execOf(value) : null;
310
323
  if (found) return found;
311
- if (!value && argMeta.option?.onlyApp && appNames.length === 1 && appNames[0])
324
+ if (!value && !destructive && argMeta.option?.onlyApp && appNames.length === 1 && appNames[0])
312
325
  return AppExecutor.from(workspace, appNames[0]);
313
326
  const [kind, placeholder] =
314
327
  sysType === "exec" ? ["app, lib or pkg", "<app-lib-or-pkg>"] : ["app or lib", "<app-or-lib>"];
315
328
  const choices = [...appNames, ...libNames, ...pkgChoices];
316
329
  const only = choices.length === 1 ? execOf(choices[0] ?? "") : null;
317
330
  //? Not for Lib, Pkg or Module, which name what a command acts on: `akan remove-library` would delete the only one.
318
- if (!value && only && !process.stdin.isTTY) {
331
+ if (!value && !destructive && only && !process.stdin.isTTY) {
319
332
  const how = rerun ? `: ${rerun(only.name)}` : "";
320
333
  Logger.rawLog(
321
334
  `No ${kind} named, so this runs on '${only.name}', the only one in this workspace${how}`,
@@ -324,7 +337,7 @@ export const getInternalArgumentValue = async (
324
337
  );
325
338
  return only;
326
339
  }
327
- assertPromptable(problemOf(kind), { choices, ...rerunRemedyOf(rerun, placeholder) });
340
+ assertAskable(kind, choices, placeholder);
328
341
  const name = await select<string>({
329
342
  message: sysType === "exec" ? `Select the App or Lib or Pkg name` : `Select the App or Lib name`,
330
343
  choices,
@@ -334,19 +347,19 @@ export const getInternalArgumentValue = async (
334
347
  return picked;
335
348
  } else if (sysType === "app") {
336
349
  if (value && appNames.includes(value)) return AppExecutor.from(workspace, value);
337
- if (!value && appNames.length === 1 && appNames[0]) return AppExecutor.from(workspace, appNames[0]);
338
- assertPromptable(problemOf("app"), { choices: appNames, ...rerunRemedyOf(rerun, "<app>") });
350
+ if (!value && !destructive && appNames.length === 1 && appNames[0]) return AppExecutor.from(workspace, appNames[0]);
351
+ assertAskable("app", appNames, "<app>");
339
352
  const appName = await select<string>({ message: `Select the ${sysType} name`, choices: appNames });
340
353
  return AppExecutor.from(workspace, appName);
341
354
  } else if (sysType === "lib") {
342
355
  if (value && libNames.includes(value)) return LibExecutor.from(workspace, value);
343
- assertPromptable(problemOf("lib"), { choices: libNames, ...rerunRemedyOf(rerun, "<lib>") });
356
+ assertAskable("lib", libNames, "<lib>");
344
357
  const libName = await select<string>({ message: `Select the ${sysType} name`, choices: libNames });
345
358
  return LibExecutor.from(workspace, libName);
346
359
  } else if (sysType === "pkg") {
347
360
  const pkgs = await workspace.getPkgs();
348
361
  if (value && pkgs.includes(value)) return PkgExecutor.from(workspace, value);
349
- assertPromptable(problemOf("pkg"), { choices: pkgs, ...rerunRemedyOf(rerun, "<pkg>") });
362
+ assertAskable("pkg", pkgs, "<pkg>");
350
363
  const pkgName = await select<string>({ message: `Select the ${sysType} name`, choices: pkgs });
351
364
  return PkgExecutor.from(workspace, pkgName);
352
365
  } else if (sysType === "module") {
@@ -368,10 +381,13 @@ export const getInternalArgumentValue = async (
368
381
  ...appNames.map((name) => AppExecutor.from(workspace, name)),
369
382
  ...libNames.map((name) => LibExecutor.from(workspace, name)),
370
383
  ];
371
- const choices = process.stdin.isTTY
372
- ? []
373
- : (await Promise.all(systems.map(async (sys) => (await sys.getModules()).map((m) => `${sys.name}:${m}`)))).flat();
374
- assertPromptable(problemOf("module"), { choices, ...rerunRemedyOf(rerun, "<sys:module>") });
384
+ const choices =
385
+ process.stdin.isTTY && !destructive
386
+ ? []
387
+ : (
388
+ await Promise.all(systems.map(async (sys) => (await sys.getModules()).map((m) => `${sys.name}:${m}`)))
389
+ ).flat();
390
+ assertAskable("module", choices, "<sys:module>");
375
391
  const { type, name } = await select<{ type: "app" | "lib"; name: string }>({
376
392
  message: `select the App or Lib name`,
377
393
  choices: [
@@ -503,6 +519,7 @@ export const runCommands = async (...commands: CommandCls[]) => {
503
519
  cmdArgs[argMeta.idx] as string,
504
520
  workspace,
505
521
  rerun,
522
+ { destructive: targetMeta.targetOption.destructive },
506
523
  );
507
524
  if (commandArgs[argMeta.idx] instanceof AppExecutor)
508
525
  process.env.AKAN_PUBLIC_APP_NAME = (commandArgs[argMeta.idx] as AppExecutor).name;
@@ -551,6 +551,63 @@ describe("a Sys that defaults to the only app", () => {
551
551
  );
552
552
  expect(asked).toEqual([]);
553
553
  });
554
+
555
+ test("a destructive command never takes the only app, lib, pkg or module, nor asks, terminal or not", async () => {
556
+ const workspace = workspaceWith([]);
557
+ workspace.getExecs = async () => [["web"], ["util"], []];
558
+ workspace.getPkgs = async () => ["tools"];
559
+ workspace.getDirInModule = async (basePath) => (basePath === "apps/web/lib" ? ["pokemon"] : []);
560
+ const refusedAs = (rerun: string, choices: string) =>
561
+ `: this command deletes files and never picks its target, so rerun it as \`${rerun}\` (choices: ${choices}).`;
562
+ const resolve = (type: InternalArgMeta["type"], commandName: string, operands: (string | undefined)[]) =>
563
+ getInternalArgumentValue(
564
+ { key: "", idx: operands.length - 1, type },
565
+ operands.at(-1),
566
+ workspace,
567
+ rerunCommandOf(
568
+ [commandName, ...operands.filter((operand): operand is string => operand !== undefined)],
569
+ operands,
570
+ operands.length - 1,
571
+ ),
572
+ { destructive: true },
573
+ );
574
+ for (const isTTY of [true, undefined]) {
575
+ setTTY(isTTY);
576
+ answer = "web";
577
+ await expect(resolve("App", "remove-application", [undefined])).rejects.toThrow(
578
+ `Missing app${refusedAs("akan remove-application <app>", "web")}`,
579
+ );
580
+ await expect(resolve("Lib", "remove-library", [undefined])).rejects.toThrow(
581
+ `Missing lib${refusedAs("akan remove-library <lib>", "util")}`,
582
+ );
583
+ await expect(resolve("Pkg", "remove-package", [undefined])).rejects.toThrow(
584
+ `Missing pkg${refusedAs("akan remove-package <pkg>", "tools")}`,
585
+ );
586
+ await expect(resolve("Module", "remove-module", [undefined])).rejects.toThrow(
587
+ `Missing module${refusedAs("akan remove-module <sys:module>", "web:pokemon")}`,
588
+ );
589
+ await expect(resolve("Sys", "remove-scalar", ["place", undefined])).rejects.toThrow(
590
+ `Missing app or lib${refusedAs("akan remove-scalar place <app-or-lib>", "web, util")}`,
591
+ );
592
+ await expect(resolve("App", "remove-application", ["webb"])).rejects.toThrow(
593
+ `Unknown app 'webb'${refusedAs("akan remove-application <app>", "web")}`,
594
+ );
595
+ }
596
+ expect(asked).toEqual([]);
597
+ });
598
+
599
+ test("a destructive command takes the target it is named, while another still takes the only app", async () => {
600
+ setTTY(undefined);
601
+ const workspace = workspaceWith([]);
602
+ workspace.getExecs = async () => [["web"], [], []];
603
+ const named = await getInternalArgumentValue({ key: "", idx: 0, type: "App" }, "web", workspace, undefined, {
604
+ destructive: true,
605
+ });
606
+ expect(named instanceof AppExecutor ? named.name : null).toBe("web");
607
+ const taken = await getInternalArgumentValue(sysMetaOf("typecheck"), undefined, workspace);
608
+ expect(taken instanceof AppExecutor ? taken.name : null).toBe("web");
609
+ expect(asked).toEqual([]);
610
+ });
554
611
  });
555
612
 
556
613
  describe("the line a refused target is rerun with", () => {
@@ -573,7 +630,7 @@ describe("the line a refused target is rerun with", () => {
573
630
 
574
631
  describe("a target left out where no terminal can ask for it", () => {
575
632
  const track = tempRoots();
576
- const run = async (apps: string[]) => {
633
+ const run = async (apps: string[], args = ["create-thing", "photo", "--force"]) => {
577
634
  const { root } = track(await makeCliTempWorkspace());
578
635
  await writeText(path.join(root, "package.json"), '{ "name": "repo", "version": "1.0.0" }\n');
579
636
  await writeText(path.join(root, "tsconfig.json"), "{}\n");
@@ -582,7 +639,7 @@ describe("a target left out where no terminal can ask for it", () => {
582
639
  await writeText(
583
640
  entry,
584
641
  [
585
- `import { command, runCommands, Sys } from ${JSON.stringify(path.join(import.meta.dir, "index.ts"))};`,
642
+ `import { App, command, runCommands, Sys } from ${JSON.stringify(path.join(import.meta.dir, "index.ts"))};`,
586
643
  'class ThingCommand extends command("thing", ({ public: target }) => ({',
587
644
  ' createThing: target({ desc: "creates a thing" })',
588
645
  ' .arg("thing", String, { desc: "name of the thing" })',
@@ -591,12 +648,17 @@ describe("a target left out where no terminal can ask for it", () => {
591
648
  " .exec(async function (thing, sys, force) {",
592
649
  ' process.stdout.write(thing + " in " + sys.name + ", force " + force + "\\n");',
593
650
  " }),",
651
+ ' removeThing: target({ desc: "removes an app", destructive: true })',
652
+ " .with(App)",
653
+ " .exec(async function (app) {",
654
+ ' process.stdout.write("removed " + app.name + "\\n");',
655
+ " }),",
594
656
  "})) {}",
595
657
  "await runCommands(ThingCommand);",
596
658
  "",
597
659
  ].join("\n"),
598
660
  );
599
- const proc = Bun.spawn(["bun", entry, "create-thing", "photo", "--force"], {
661
+ const proc = Bun.spawn(["bun", entry, ...args], {
600
662
  cwd: root,
601
663
  stdin: "ignore",
602
664
  stdout: "pipe",
@@ -628,6 +690,16 @@ describe("a target left out where no terminal can ask for it", () => {
628
690
  expect(refused.stdout).toMatch(/\(choices: (web, admin|admin, web)\)\./);
629
691
  expect(refused.stdout).not.toContain("photo in");
630
692
  }, 30_000);
693
+
694
+ test("a destructive command exits with the line to rerun even when the workspace has one app", async () => {
695
+ const refused = await run(["web"], ["remove-thing"]);
696
+ expect(refused.code).toBe(1);
697
+ expect(refused.stdout).toContain(
698
+ "Missing app: this command deletes files and never picks its target, so rerun it as `akan remove-thing <app>` (choices: web).",
699
+ );
700
+ expect(refused.stdout).not.toContain("removed");
701
+ expect((await run(["web"], ["remove-thing", "web"])).stdout).toContain("removed web\n");
702
+ }, 30_000);
631
703
  });
632
704
 
633
705
  describe("the exit code of a command that failed", () => {
@@ -45,6 +45,8 @@ export interface TargetOption {
45
45
  stdio?: boolean;
46
46
  failureExitCode?: number;
47
47
  usageExitCode?: number;
48
+ /** It deletes files in its app, lib, pkg or module, so one left out or unknown is refused, never picked or asked. */
49
+ destructive?: boolean;
48
50
  /** Runs after the CLI printed a failure and before it exits, so what it writes ends the output. */
49
51
  onFailure?: (error: unknown, failure: TargetFailure) => void | Promise<void>;
50
52
  }
@@ -0,0 +1,34 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { constantFieldNames } from "./constantFieldNames";
3
+
4
+ describe("constantFieldNames", () => {
5
+ test("names each field the code declares, chained or not", () => {
6
+ const content = `export class TaskInput extends via((field) => ({
7
+ title: field(String),
8
+ tags: field(String).list().optional(),
9
+ workHistory: field([WorkHistory]),
10
+ })) {}
11
+ export class TaskObject extends via(TaskInput, (field) => ({
12
+ due: field(Date).optional(),
13
+ total: resolve<number>(),
14
+ })) {}
15
+ `;
16
+
17
+ expect(constantFieldNames(content)).toEqual(["title", "tags", "workHistory", "due"]);
18
+ });
19
+
20
+ test("leaves out a field that appears only in a comment or a string", () => {
21
+ const content = `export class TaskInput extends via((field) => ({
22
+ title: field(String),
23
+ // priority: field(TaskPriority, { default: "medium" })
24
+ /* assignee: field(ID).optional() */
25
+ note: field(String, { default: "completedAt: field(Date)" }),
26
+ })) {}
27
+ // ---- Expandable additional fields: ----
28
+ // - actor: field(String).optional() — who performed the action
29
+ export const example = \`previousStatus: field(TaskStatus)\`;
30
+ `;
31
+
32
+ expect(constantFieldNames(content)).toEqual(["title", "note"]);
33
+ });
34
+ });
@@ -0,0 +1,18 @@
1
+ import ts from "typescript";
2
+
3
+ const isFieldCall = (node: ts.Expression): boolean =>
4
+ ts.isCallExpression(node) &&
5
+ (ts.isIdentifier(node.expression)
6
+ ? node.expression.text === "field"
7
+ : ts.isPropertyAccessExpression(node.expression) && isFieldCall(node.expression.expression));
8
+
9
+ export const constantFieldNames = (content: string) => {
10
+ const names: string[] = [];
11
+ const visit = (node: ts.Node) => {
12
+ if (ts.isPropertyAssignment(node) && ts.isIdentifier(node.name) && isFieldCall(node.initializer))
13
+ names.push(node.name.text);
14
+ ts.forEachChild(node, visit);
15
+ };
16
+ visit(ts.createSourceFile("constant.ts", content, ts.ScriptTarget.Latest));
17
+ return names;
18
+ };
@@ -20,10 +20,20 @@ const publicBlobOf = (publicKey: string) => {
20
20
  return parsed.getPublicSSH();
21
21
  };
22
22
 
23
+ //? ssh2 1.17's keygen strips a leading zero byte off an ed25519 public key, so 1 pair in 256 cannot be read back.
24
+ const ed25519Pair = () => {
25
+ for (let tries = 1; ; tries++) {
26
+ const pair = utils.generateKeyPairSync("ed25519");
27
+ const parsed = utils.parseKey(pair.private);
28
+ if (!(parsed instanceof Error)) return pair;
29
+ if (tries === 8) throw parsed;
30
+ }
31
+ };
32
+
23
33
  const sshServers: { server: Server; clients: Set<Connection> }[] = [];
24
34
 
25
35
  const startSshServer = async () => {
26
- const hostKey = utils.generateKeyPairSync("ed25519");
36
+ const hostKey = ed25519Pair();
27
37
  const methods: string[] = [];
28
38
  const clients = new Set<Connection>();
29
39
  const server = new Server({ hostKeys: [hostKey.private] }, (client) => {
@@ -136,7 +146,7 @@ describe("createTunnel", () => {
136
146
  const ssh = await startSshServer();
137
147
  const knownHosts = path.join(root, "known_hosts.json");
138
148
  const keyPath = path.join(root, "id_tunnel");
139
- await writeFile(keyPath, utils.generateKeyPairSync("ed25519").private);
149
+ await writeFile(keyPath, ed25519Pair().private);
140
150
  Object.assign(process.env, {
141
151
  SSH_TUNNEL_PORT: String(ssh.port),
142
152
  SSH_TUNNEL_KNOWN_HOSTS: knownHosts,
@@ -343,6 +343,49 @@ describe("route client store bootstrap", () => {
343
343
  expect(bundled).not.toContain("lib-ui-marker");
344
344
  });
345
345
 
346
+ test("a route's bundle runs the client runtime and the stores before the UI it renders, which reads them on load", async () => {
347
+ const { appDir, app } = await writeBarrelFixture();
348
+ const record = (label: string) =>
349
+ `((globalThis as { __akanOrder?: string[] }).__akanOrder ??= []).push("${label}");`;
350
+ await write(
351
+ path.join(appDir, "lib/useClient.ts"),
352
+ `${record("runtime")}\nexport const { msg, Err, usePage, fetch, sig } = { usePage: () => "page" } as Record<string, unknown>;\n`,
353
+ );
354
+ await write(
355
+ path.join(appDir, "../../libs/kit/lib/st.ts"),
356
+ `${record("lib-store")}\nexport class RootStore {}\nexport const st = Object.freeze({ mark: "lib-store-marker" });\n`,
357
+ );
358
+ await write(
359
+ path.join(appDir, "lib/st.ts"),
360
+ `import { RootStore as kit } from "@libs/kit/client";\n${record("store")}\nexport class RootStore {}\nexport const st = Object.freeze({ mark: "store-marker", kit });\n`,
361
+ );
362
+ await write(
363
+ path.join(appDir, "lib/memo/Memo.Zone.tsx"),
364
+ `"use client";\nimport { Memo, st } from "@apps/demo/client";\n${record("memo-ui")}\nexport const seen = [st.mark, typeof Memo];\n`,
365
+ );
366
+
367
+ await new RouteClientBuilder({
368
+ app,
369
+ seeds: [],
370
+ entries: [path.join(appDir, "lib/memo/Memo.Zone.tsx")],
371
+ artifact: {} as never,
372
+ browser: "chunks",
373
+ }).build();
374
+
375
+ const clientOut = path.join(appDir, ".akan/artifact/client");
376
+ const entries = [...new Bun.Glob("*.js").scanSync(clientOut)];
377
+ expect(entries).toHaveLength(1);
378
+ const loaded = (await import(path.join(clientOut, entries[0] ?? ""))) as { seen?: unknown };
379
+
380
+ expect(loaded.seen).toEqual(["store-marker", "object"]);
381
+ expect((globalThis as { __akanOrder?: string[] }).__akanOrder).toEqual([
382
+ "runtime",
383
+ "lib-store",
384
+ "store",
385
+ "memo-ui",
386
+ ]);
387
+ });
388
+
346
389
  test("a route reaching a lib's cnst through its client barrel carries no module UI", async () => {
347
390
  const { root, appDir, app } = await writeBarrelFixture();
348
391
 
@@ -57,6 +57,28 @@ describe("GraphClientEntryDiscovery", () => {
57
57
  expect(await discovery.discover([file("S.tsx")])).toEqual([file("ui/Card.ts"), file("ui/Chart/index.tsx")]);
58
58
  });
59
59
 
60
+ test("an export moved into a client file of a barrel is found through the importer that names it", async () => {
61
+ const file = await tempDir();
62
+ await Bun.write(file("ui/index.ts"), 'export * from "./Foo";\nexport * from "./Other";\n');
63
+ await Bun.write(file("ui/Foo.tsx"), "export const Foo = 1;\nexport const Bar = () => null;\n");
64
+ await Bun.write(file("ui/Other.tsx"), '"use client";\nexport const Other = () => null;\n');
65
+ await Bun.write(file("S.tsx"), 'import { Bar } from "@demo/ui";\nexport const s = Bar;\n');
66
+ const resolvePackage = async (pkgName: string) => {
67
+ const leaf = pkgName === "@demo/ui" ? "index.ts" : `${pkgName.slice("@demo/ui/".length)}.tsx`;
68
+ return { pkgName, entryFile: file(`ui/${leaf}`), pkgDir: file("ui") };
69
+ };
70
+ const discovery = new GraphClientEntryDiscovery({ barrelImports: ["@demo/ui"] }, resolvePackage as never);
71
+ expect(await discovery.discover([file("S.tsx")])).toEqual([]);
72
+
73
+ await Bun.write(file("ui/Foo.tsx"), "export const Foo = 1;\n");
74
+ await Bun.write(
75
+ file("ui/Other.tsx"),
76
+ '"use client";\nexport const Other = () => null;\nexport const Bar = () => null;\n',
77
+ );
78
+ discovery.invalidate([file("ui/Foo.tsx"), file("ui/Other.tsx")]);
79
+ expect(await discovery.discover([file("S.tsx")])).toEqual([file("ui/Other.tsx")]);
80
+ });
81
+
60
82
  test("a resolution still in flight when its target appears is not what the next walk reads", async () => {
61
83
  const file = await tempDir();
62
84
  await Bun.write(file("S.tsx"), 'import "./B";\n');