@akanjs/devkit 3.1.0-rc.25 → 3.1.0-rc.27

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.
@@ -172,4 +172,40 @@ console.info(new SampleService().name);
172
172
  await rm(root, { recursive: true, force: true });
173
173
  }
174
174
  });
175
+
176
+ test("asks the bundler for a metafile with AKAN_BUILD_METAFILE=1, and not with 0, false or an empty value", async () => {
177
+ const { root, workspace } = await createTempApp("portal");
178
+ const keys = [
179
+ "AKAN_BUILD_METAFILE",
180
+ "AKAN_PUBLIC_ENV",
181
+ "AKAN_PUBLIC_REPO_NAME",
182
+ "AKAN_PUBLIC_SERVE_DOMAIN",
183
+ ] as const;
184
+ const shellEnv = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
185
+ await Promise.all(["main.ts", "server.ts"].map((file) => writeFile(path.join(root, "apps", "portal", file), "")));
186
+ const app = AppExecutor.from(workspace, "portal");
187
+ app.prepareCommand = async () => ({ env: {} });
188
+ const bundle = spyOn(Bun, "build").mockImplementation(async () => {
189
+ throw new Error("stopped at the first bundle");
190
+ });
191
+ const asksForMetafile = async (flag: string) => {
192
+ process.env.AKAN_BUILD_METAFILE = flag;
193
+ await expect(new ApplicationBuildRunner(app, { fast: true }).build()).rejects.toThrow(
194
+ "stopped at the first bundle",
195
+ );
196
+ return bundle.mock.lastCall?.[0].metafile === true;
197
+ };
198
+ try {
199
+ const asked: { [flag: string]: boolean } = {};
200
+ for (const flag of ["1", "0", "false", ""]) asked[flag] = await asksForMetafile(flag);
201
+ expect(asked).toEqual({ "1": true, "0": false, false: false, "": false });
202
+ } finally {
203
+ bundle.mockRestore();
204
+ for (const key of keys) {
205
+ if (shellEnv[key] === undefined) delete process.env[key];
206
+ else process.env[key] = shellEnv[key];
207
+ }
208
+ await rm(root, { recursive: true, force: true });
209
+ }
210
+ });
175
211
  });
@@ -387,9 +387,11 @@ void run().catch((error) => {
387
387
 
388
388
  //? Opt-in and outside `dist/`: a metafile is the size of the module graph, and `dist/` is what the image copies.
389
389
  async #buildOrThrow(label: string, config: Bun.BuildConfig): Promise<Bun.BuildOutput> {
390
- const metafileDir = process.env.AKAN_BUILD_METAFILE
391
- ? path.join(this.#app.workspace.workspaceRoot, "local", "build-meta", path.basename(this.#app.cwdPath))
392
- : null;
390
+ const flag = process.env.AKAN_BUILD_METAFILE;
391
+ const metafileDir =
392
+ flag && flag !== "0" && flag !== "false"
393
+ ? path.join(this.#app.workspace.workspaceRoot, "local", "build-meta", path.basename(this.#app.cwdPath))
394
+ : null;
393
395
  const result = await Bun.build(metafileDir ? { ...config, metafile: true } : config);
394
396
  if (!result.success) throw new AggregateError(result.logs, `[${label}] Bun.build failed`);
395
397
  if (metafileDir && result.metafile) {
package/biome.base.json CHANGED
@@ -196,6 +196,7 @@
196
196
  },
197
197
  {
198
198
  "includes": ["{apps,libs}/**/*.tsx", "!**/*.test.tsx", "!**/*.spec.tsx"],
199
+ "linter": { "rules": { "correctness": { "useHookAtTopLevel": "error" } } },
199
200
  "plugins": [
200
201
  "./node_modules/@akanjs/devkit/lint/no-unpublished-form-setter.grit",
201
202
  "./node_modules/@akanjs/devkit/lint/no-conditional-st-hook.grit"
@@ -0,0 +1,78 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { spawn } from "node:child_process";
3
+ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { biomeBaseConfig } from "./biomeBase";
7
+ import { Linter, parseBiomeReport } from "./linter";
8
+
9
+ const devkitDir = import.meta.dir;
10
+ const biomeBin = path.resolve(devkitDir, "../../../node_modules/.bin", Linter.biomeBinName);
11
+
12
+ const hookProbe = `export const Early = ({ id }: { id?: string }) => {
13
+ if (!id) return null;
14
+ const [count] = useState(0);
15
+ return <p>{count}</p>;
16
+ };
17
+ export const Branch = ({ open }: { open: boolean }) => {
18
+ if (open) useEffect(() => {}, []);
19
+ return null;
20
+ };
21
+ `;
22
+
23
+ const tempDirs: string[] = [];
24
+
25
+ afterAll(async () => {
26
+ await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
27
+ });
28
+
29
+ const runBiome = (args: string[], cwd: string) =>
30
+ new Promise<string>((resolve, reject) => {
31
+ const proc = spawn(biomeBin, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
32
+ let stdout = "";
33
+ let stderr = "";
34
+ proc.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString()));
35
+ proc.stderr.on("data", (chunk: Buffer) => (stderr += chunk.toString()));
36
+ proc.on("error", reject);
37
+ proc.on("close", () => resolve(stdout || stderr));
38
+ });
39
+
40
+ const lintWorkspace = async (files: string[]) => {
41
+ const dir = await mkdtemp(path.join(os.tmpdir(), "biome-base-"));
42
+ tempDirs.push(dir);
43
+ //? The base config names its plugins as `./node_modules/@akanjs/devkit/…`, resolved from the extending config's
44
+ //? folder. A junction needs no privilege on Windows, and the type is ignored elsewhere.
45
+ await mkdir(path.join(dir, "node_modules", "@akanjs"), { recursive: true });
46
+ await symlink(devkitDir, path.join(dir, "node_modules", "@akanjs", "devkit"), "junction");
47
+ await writeFile(path.join(dir, "biome.json"), JSON.stringify({ extends: [biomeBaseConfig] }));
48
+ for (const file of files) {
49
+ await mkdir(path.dirname(path.join(dir, file)), { recursive: true });
50
+ await writeFile(path.join(dir, file), hookProbe);
51
+ }
52
+ const output = await runBiome(["lint", "--reporter=json", "--max-diagnostics=none", ...files], dir);
53
+ return parseBiomeReport(output).diagnostics ?? [];
54
+ };
55
+
56
+ describe("biome.base.json", () => {
57
+ test("reports a conditional React hook in app and lib components, not in their tests or in packages", async () => {
58
+ const diagnostics = await lintWorkspace([
59
+ "apps/demo/ui/Probe.tsx",
60
+ "libs/demo/ui/Probe.tsx",
61
+ "apps/demo/ui/Probe.test.tsx",
62
+ "pkgs/demo/Probe.tsx",
63
+ ]);
64
+ const hookReports = diagnostics
65
+ .filter((diagnostic) => diagnostic.category === "lint/correctness/useHookAtTopLevel")
66
+ .map(
67
+ (diagnostic) =>
68
+ `${diagnostic.location?.path?.replaceAll("\\", "/")}:${diagnostic.location?.start?.line} ${diagnostic.severity}`,
69
+ )
70
+ .sort();
71
+ expect(hookReports).toEqual([
72
+ "apps/demo/ui/Probe.tsx:3 error",
73
+ "apps/demo/ui/Probe.tsx:7 error",
74
+ "libs/demo/ui/Probe.tsx:3 error",
75
+ "libs/demo/ui/Probe.tsx:7 error",
76
+ ]);
77
+ });
78
+ });
@@ -19,7 +19,8 @@ For a model `comment` (`<Slice>` is a signal slice suffix, e.g. `InPublic`):
19
19
  `add<Field>OnComment(value)` / `sub<Field>OnComment(idx)` for arrays, `upload<Field>OnComment(fileList)`
20
20
  for file fields, `checkCommentSubmitable()`, `submitComment()`, `createCommentInForm()`,
21
21
  `updateCommentInForm()`
22
- - single: `viewComment(model)`, `mergeComment(model, data)`, `setComment(...models)`, `resetComment()`
22
+ - single: `viewComment(model)`, `mergeComment(model, data)`, `setComment(...models)`, `resetComment()`,
23
+ `invalidateComment()` (marks every slice stale after a custom mutation, so the next mount refetches)
23
24
  - per slice: `initComment<Slice>(...args)`, `refreshComment<Slice>()`, `selectComment<Slice>(model)`,
24
25
  `setPageOfComment<Slice>(page)`, `loadMoreOfComment<Slice>()`, `setLimitOfComment<Slice>(limit)`,
25
26
  `setQueryArgsOfComment<Slice>(...)`, `setSortOfComment<Slice>(sort)`
@@ -1,3 +1,4 @@
1
+ import { lookup } from "node:dns/promises";
1
2
  import { isIP } from "node:net";
2
3
  import type { ExtensionAPI, InlineExtension } from "@earendil-works/pi-coding-agent";
3
4
  import { type CodeAgentProfile, codeAgentClip } from "akanjs/common";
@@ -8,6 +9,7 @@ export interface WebToolPackOptions {
8
9
  profile: CodeAgentProfile;
9
10
  /** Serves search after any key-named provider, and reads public pages to Markdown ahead of a direct fetch. */
10
11
  deepseek?: DeepSeekWeb | undefined;
12
+ lookup?: ((host: string) => Promise<string[]>) | undefined;
11
13
  }
12
14
 
13
15
  interface SearchProvider {
@@ -163,7 +165,7 @@ export class WebToolPack {
163
165
 
164
166
  async #read(url: string, signal?: AbortSignal): Promise<WebPage> {
165
167
  const deepseek = this.#options.deepseek;
166
- if (deepseek && !WebToolPack.#isLocal(new URL(url).hostname)) {
168
+ if (deepseek && !(await this.#isLocal(new URL(url).hostname))) {
167
169
  try {
168
170
  const page = await deepseek.fetch(url, signal);
169
171
  return { status: 200, ok: true, title: page.title, text: page.content };
@@ -196,23 +198,46 @@ export class WebToolPack {
196
198
  }
197
199
 
198
200
  // DeepSeek's servers cannot reach these, and an intranet name has no business leaving the machine.
199
- static #isLocal(hostname: string) {
200
- const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
201
- const family = isIP(host);
202
- if (family === 6) return host === "::1" || /^(f[cd]|fe[89ab])/.test(host);
203
- if (family === 4) {
204
- const [a = 0, b = 0] = host.split(".").map(Number);
205
- return (
206
- a === 0 ||
207
- a === 10 ||
208
- a === 127 ||
209
- (a === 100 && b >= 64 && b <= 127) ||
210
- (a === 169 && b === 254) ||
211
- (a === 172 && b >= 16 && b <= 31) ||
212
- (a === 192 && b === 168)
213
- );
201
+ async #isLocal(hostname: string) {
202
+ const host = hostname.replace(/^\[|\]$|\.$/g, "").toLowerCase();
203
+ if (isIP(host)) return WebToolPack.#isLocalAddress(host);
204
+ if (!host.includes(".") || /\.(localhost|local|internal|lan|home\.arpa)$/.test(host)) return true;
205
+ try {
206
+ const addresses = await (this.#options.lookup ?? WebToolPack.#lookup)(host);
207
+ return !addresses.length || addresses.some((address) => WebToolPack.#isLocalAddress(address));
208
+ } catch {
209
+ // Fails closed: an intranet name this machine cannot resolve right now is still an intranet name.
210
+ return true;
214
211
  }
215
- return !host.includes(".") || /\.(localhost|local|internal|lan|home\.arpa)$/.test(host);
212
+ }
213
+
214
+ static async #lookup(host: string) {
215
+ return (await lookup(host, { all: true })).map((entry) => entry.address);
216
+ }
217
+
218
+ static #isLocalAddress(literal: string) {
219
+ const address = WebToolPack.#unmapped(literal);
220
+ if (isIP(address) === 6) return address === "::" || address === "::1" || /^(f[cd]|fe[89ab])/.test(address);
221
+ const [a = 0, b = 0] = address.split(".").map(Number);
222
+ return (
223
+ a === 0 ||
224
+ a === 10 ||
225
+ a === 127 ||
226
+ (a === 100 && b >= 64 && b <= 127) ||
227
+ (a === 169 && b === 254) ||
228
+ (a === 172 && b >= 16 && b <= 31) ||
229
+ (a === 192 && b === 168)
230
+ );
231
+ }
232
+
233
+ // The URL parser writes `[::ffff:127.0.0.1]` as `::ffff:7f00:1`; a resolver hands a mapped address back dotted.
234
+ static #unmapped(address: string) {
235
+ const mapped = /^::ffff:(?:(\d+\.\d+\.\d+\.\d+)|([0-9a-f]{1,4}):([0-9a-f]{1,4}))$/i.exec(address);
236
+ if (!mapped) return address;
237
+ if (mapped[1]) return mapped[1];
238
+ const high = Number.parseInt(mapped[2] ?? "0", 16);
239
+ const low = Number.parseInt(mapped[3] ?? "0", 16);
240
+ return [high >> 8, high & 255, low >> 8, low & 255].join(".");
216
241
  }
217
242
 
218
243
  static #refuseHost(url: string, allowHosts: string[] | undefined) {
@@ -709,9 +709,9 @@ describe("WebToolPack", () => {
709
709
  }) as typeof fetch);
710
710
  try {
711
711
  const deepseek = new DeepSeekWeb("sk-test", `http://127.0.0.1:${deepseekServer.port}/v1/tools`);
712
- const fetchTool = toolsOf(new WebToolPack({ profile: codeAgentPresets.local(root), deepseek }).extension()).get(
713
- "web_fetch",
714
- );
712
+ const lookup = async () => ["93.184.215.14"];
713
+ const pack = new WebToolPack({ profile: codeAgentPresets.local(root), deepseek, lookup });
714
+ const fetchTool = toolsOf(pack.extension()).get("web_fetch");
715
715
  const read = async (url: string) => (await fetchTool?.("1", { url } as never))?.content[0]?.text ?? "";
716
716
 
717
717
  expect(await read("https://docs.example.com/install")).toBe(
@@ -728,6 +728,74 @@ describe("WebToolPack", () => {
728
728
  }
729
729
  });
730
730
 
731
+ test("reads an intranet name, a mapped or unspecified loopback, and a name that does not resolve directly", async () => {
732
+ const asked: string[] = [];
733
+ const deepseekServer = Bun.serve({
734
+ port: 0,
735
+ fetch: async (request) => {
736
+ asked.push(((await request.json()) as { url: string }).url);
737
+ return Response.json({ title: "Public", content: "read by DeepSeek" });
738
+ },
739
+ });
740
+ const realFetch = globalThis.fetch;
741
+ const direct = spyOn(globalThis, "fetch").mockImplementation((async (
742
+ input: RequestInfo | URL,
743
+ init?: RequestInit,
744
+ ) => {
745
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
746
+ if (new URL(url).port === String(deepseekServer.port)) return await realFetch(input, init);
747
+ return new Response("<p>read directly</p>");
748
+ }) as typeof fetch);
749
+ const addresses: { [host: string]: string[] } = {
750
+ "jira.corp.example.com": ["10.0.0.7"],
751
+ "wiki.example.com": ["93.184.215.14", "fd12::1"],
752
+ "hosts.example.com": ["::ffff:192.168.0.2"],
753
+ "docs.example.com": ["93.184.215.14", "2606:2800:21f:cb07:6820:80da:af6b:8b2c"],
754
+ };
755
+ const looked: string[] = [];
756
+ const lookup = async (host: string) => {
757
+ looked.push(host);
758
+ const found = addresses[host];
759
+ if (!found) throw new Error(`getaddrinfo ENOTFOUND ${host}`);
760
+ return found;
761
+ };
762
+ try {
763
+ const deepseek = new DeepSeekWeb("sk-test", `http://127.0.0.1:${deepseekServer.port}/v1/tools`);
764
+ const pack = new WebToolPack({ profile: codeAgentPresets.local(root), deepseek, lookup });
765
+ const fetchTool = toolsOf(pack.extension()).get("web_fetch");
766
+ const read = async (url: string) => (await fetchTool?.("1", { url } as never))?.content[0]?.text ?? "";
767
+
768
+ for (const url of [
769
+ "http://jira.corp.example.com/browse/SEC-1",
770
+ "http://wiki.example.com/",
771
+ "http://hosts.example.com/",
772
+ "http://foo.test/",
773
+ "http://[::ffff:127.0.0.1]:8282/x",
774
+ "http://[::ffff:10.0.0.5]/wiki",
775
+ "http://[::]:8282/",
776
+ "http://0.0.0.0:8282/",
777
+ "http://localhost.:8282/",
778
+ "http://app.local./",
779
+ ])
780
+ expect(await read(url)).toContain("read directly");
781
+ expect(asked).toEqual([]);
782
+
783
+ expect(await read("https://docs.example.com/install")).toContain("read by DeepSeek");
784
+ expect(await read("http://[2606:4700:4700::1111]/")).toContain("read by DeepSeek");
785
+ expect(asked).toEqual(["https://docs.example.com/install", "http://[2606:4700:4700::1111]/"]);
786
+ expect(looked).toEqual([
787
+ "jira.corp.example.com",
788
+ "wiki.example.com",
789
+ "hosts.example.com",
790
+ "foo.test",
791
+ "docs.example.com",
792
+ ]);
793
+ } finally {
794
+ direct.mockRestore();
795
+ deepseekServer.stop(true);
796
+ }
797
+ });
798
+
731
799
  test("searches through DeepSeek when no key-named provider is set, and a key-named one wins", async () => {
732
800
  const had = { brave: process.env.BRAVE_API_KEY, tavily: process.env.TAVILY_API_KEY };
733
801
  const counts: number[] = [];
@@ -384,6 +384,8 @@ export const runCommands = async (...commands: CommandCls[]) => {
384
384
  Akan CLI version is mismatch with installed package. ${process.env.AKAN_VERSION} (global) vs ${installedAkanPackageJson.version} (akanjs)
385
385
  It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
386
386
  ),
387
+ undefined,
388
+ "error",
387
389
  );
388
390
  }
389
391
 
@@ -659,3 +659,42 @@ describe("a repeatable option", () => {
659
659
  expect(await collect()).toEqual([]);
660
660
  }, 30_000);
661
661
  });
662
+
663
+ describe("a workspace whose akanjs is not the CLI's version", () => {
664
+ const track = tempRoots();
665
+
666
+ test("hears so on stderr, leaving stdout to what the command prints", async () => {
667
+ const { root } = track(await makeCliTempWorkspace());
668
+ await writeText(path.join(root, "package.json"), '{ "name": "repo", "version": "1.0.0" }\n');
669
+ await writeText(path.join(root, "tsconfig.json"), "{}\n");
670
+ await writeText(
671
+ path.join(root, "node_modules", "akanjs", "package.json"),
672
+ '{ "name": "akanjs", "version": "0.0.1-older" }\n',
673
+ );
674
+ const entry = path.join(root, "cli.ts");
675
+ await writeText(
676
+ entry,
677
+ [
678
+ `import { command, runCommands, runner, script } from ${JSON.stringify(path.join(import.meta.dir, "index.ts"))};`,
679
+ 'class PlanRunner extends runner("plan") {}',
680
+ 'class PlanScript extends script("plan", [PlanRunner]) {}',
681
+ 'class PlanCommand extends command("plan", [PlanScript], ({ public: target }) => ({',
682
+ ' plan: target({ desc: "plans", stdio: true }).exec(async function () {',
683
+ " process.stdout.write(JSON.stringify({ phases: 1 }));",
684
+ " }),",
685
+ "})) {}",
686
+ "await runCommands(PlanCommand);",
687
+ "",
688
+ ].join("\n"),
689
+ );
690
+ const proc = Bun.spawn(["bun", entry, "plan"], { cwd: root, stdout: "pipe", stderr: "pipe" });
691
+ const [stdout, stderr, code] = await Promise.all([
692
+ new Response(proc.stdout).text(),
693
+ new Response(proc.stderr).text(),
694
+ proc.exited,
695
+ ]);
696
+ expect(code).toBe(0);
697
+ expect(stdout).toBe('{"phases":1}');
698
+ expect(stderr).toContain("0.0.1-older (akanjs)");
699
+ }, 30_000);
700
+ });
package/executors.test.ts CHANGED
@@ -429,6 +429,35 @@ describe("Workspace and app executor environment contracts", () => {
429
429
  });
430
430
  });
431
431
 
432
+ test("reads an explicit env file as Bun's own loader does, leaving inline comments out of the values", async () => {
433
+ const root = await makeTempRoot();
434
+ await writeFile(
435
+ path.join(root, ".env"),
436
+ [
437
+ "# the workspace env",
438
+ "AKAN_PUBLIC_SERVE_DOMAIN=example.com # prod domain",
439
+ 'AKAN_PUBLIC_REPO_NAME="file repo" # note',
440
+ "AKAN_PUBLIC_ENV=develop#no space needed",
441
+ "export AKAN_WORKSPACE_ID='ws # kept' # dropped",
442
+ 'AKAN_PUBLIC_APP_NAME="say \\"hi\\"\\nagain" # c',
443
+ 'AKAN_WORKSPACE_ROOT="/unterminated # c',
444
+ "PORT_OFFSET=7",
445
+ "",
446
+ ].join("\r\n"),
447
+ );
448
+
449
+ expect(WorkspaceExecutor.getBaseDevEnv(path.join(root, ".env"))).toEqual({
450
+ appName: 'say \\"hi\\"\nagain',
451
+ workspaceRoot: '"/unterminated',
452
+ repoName: "file repo",
453
+ serveDomain: "example.com",
454
+ env: "develop",
455
+ portOffset: 7,
456
+ workspaceId: "ws # kept",
457
+ defaulted: [],
458
+ });
459
+ });
460
+
432
461
  test("builds app command environment and prepareCommand artifacts", async () => {
433
462
  const root = await makeTempRoot();
434
463
  process.env.AKAN_PUBLIC_REPO_NAME = "repo";
package/executors.ts CHANGED
@@ -140,6 +140,14 @@ export const execEmoji = {
140
140
  default: "✈️",
141
141
  };
142
142
 
143
+ //? As Bun's own .env loader reads it (1.4.4), which fills process.env too: `#` ends an unquoted value even mid-word.
144
+ const parseEnvValue = (value: string) => {
145
+ const [, quote, inner] = value.match(/^(["'`])((?:\\.|(?!\1)[^\\])*)\1/) ?? [];
146
+ if (!quote) return value.replace(/#.*/, "").trim();
147
+ if (quote !== '"') return inner;
148
+ return inner.replace(/\\(.)/g, (pair, char: string) => (char === "n" ? "\n" : char === "r" ? "\r" : pair));
149
+ };
150
+
143
151
  const parseEnvFile = (envPath: string): Record<string, string> => {
144
152
  const env: Record<string, string> = {};
145
153
  const content = (() => {
@@ -156,11 +164,7 @@ const parseEnvFile = (envPath: string): Record<string, string> => {
156
164
  const separatorIndex = normalized.indexOf("=");
157
165
  if (separatorIndex <= 0) continue;
158
166
  const key = normalized.slice(0, separatorIndex).trim();
159
- let value = normalized.slice(separatorIndex + 1).trim();
160
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
161
- value = value.slice(1, -1);
162
- }
163
- env[key] = value;
167
+ env[key] = parseEnvValue(normalized.slice(separatorIndex + 1).trim());
164
168
  }
165
169
  return env;
166
170
  };
@@ -17,8 +17,29 @@ export const A = () => {
17
17
  const [l] = open ? st.useState("l", String).desc("d").init("") : []; // @flag
18
18
  if (open) st.expose("m", String).desc("d").value("x"); // @flag
19
19
  const save = st.tool("z").desc("d").exec(async () => { st.use.n(); }); // @flag
20
+ const { title = st.use.title() } = props; // @flag
21
+ let cached = initial; cached ||= st.use.s(); // @flag
22
+ cached ??= st.use.t(); // @flag
23
+ cached &&= st.use.u(); // @flag
24
+ for (let i = 0; i < st.use.count(); i++) run(i); // @flag
25
+ const formatted = props.format?.(st.use.v()); // @flag
26
+ const chained = props.api?.format(st.use.w()); // @flag
27
+ const computed = open ? st["use"].x() : null; // @flag
28
+ const computedKey = open ? st.use["y"]() : null; // @flag
29
+ const computedSlice = open ? st.slice["inOrg"].use.z() : null; // @flag
30
+ useEffect(() => { void (async () => { await load(); st.use.aa(); })(); }, []); // @flag
20
31
  return <div onClick={() => st.use.o()} />; // @flag
21
32
  };
22
33
  export const B = () => <Load.Units renderList={(list) => <X a={st.use.p()} />} />; // @flag
23
34
  export const C = () => <Load.Stream of={q}>{(v) => <X a={st.sel((s) => s.q)} />}</Load.Stream>; // @flag
24
35
  export const useD = () => useMemo(() => st.use.r(), []); // @flag
36
+ export const E = ({ name = st.use.defaultName() }) => <p>{name}</p>; // @flag
37
+ export function F({ name = st.sel((s) => s.name) }) { return <p>{name}</p>; } // @flag
38
+ export const G = () => {
39
+ const renderRow = (item) => <X a={st.use.bb()} />; // @flag
40
+ return <Load.Units renderItem={renderRow} />;
41
+ };
42
+ export const H = () => {
43
+ function row(item) { return <X a={st.ref((s) => s.cc)} />; } // @flag
44
+ return <ul>{items.map(row)}</ul>;
45
+ };
@@ -20,3 +20,14 @@ export const C = forwardRef((props, ref) => { const b = st.sel((s) => s.b); retu
20
20
  export const useTools = () => { const tool = (name) => st.tool(name).desc("d").exec(() => run()); tool("a"); }; // @ok
21
21
  export const D = () => <Load.Units renderItem={(item) => <Row key={item.id} id={item.id} />} />; // @ok
22
22
  export const E = () => { const busy = open && other.use.x(); return busy; }; // @ok
23
+ export const F = () => { const Submit = useMemo(() => () => { const v = st.use.x(); return v; }, []); return <Submit />; }; // @ok
24
+ export const G = () => { const Submit = useMemo(() => (open ? () => null : () => { const save = st.tool("s").desc("d").exec(run); return <Button onClick={save} />; }), [open]); return <Submit />; }; // @ok
25
+ export const H = () => { for (let i = st.use.start(); i < 3; i++) run(i); return null; }; // @ok
26
+ export const I = isNative ? () => { const a = st.use.a(); return a; } : () => null; // @ok
27
+ export const J = () => { const a = (() => st.use.x())(); return a; }; // @ok
28
+ export const K = () => { const Row = () => { const a = st.use.a(); return a; }; return <List row={Row} />; }; // @ok
29
+ const renderPage = () => { const a = st.use.a(); return a; }; // @ok
30
+ export const L = page().render(renderPage); // @ok
31
+ export const M = () => { const v = st.use.x(); props.onPick?.(v); return v; }; // @ok
32
+ export const N = () => { const a = st["use"].a(); const b = st.use["b"](); return a ?? b; }; // @ok
33
+ export const O = () => { let v = cached; v = st.use.a(); return v; }; // @ok
@@ -1,16 +1,20 @@
1
1
  engine biome(1.0)
2
2
  language js(typescript, jsx)
3
3
 
4
- // `st.use.<key>()`, `st.sel()`, `st.ref()` and the builders' last step (`st.tool(…)….exec()` / `.card()`,
5
- // `st.useState(…)….init()`, `st.expose(…)….value()`) are React hooks that Biome's `useHookAtTopLevel` cannot see:
6
- // it recognises a hook by a `use` prefix on the called name. A hook a render skips shifts every later hook by one
7
- // slot, and production React does not check the order — a later `useRef` then receives a frozen store value and
8
- // the screen dies far from the cause ("Cannot add property current, object is not extensible").
4
+ // `st.use.<key>()`, `st.slice.<name>.use.<key>()`, `st.sel()`, `st.ref()` and the builders' last step
5
+ // (`st.tool(…)….exec()` / `.card()`, `st.useState(…)….init()`, `st.expose(…)….value()`) are React hooks that Biome's
6
+ // `useHookAtTopLevel` cannot see: it recognises a hook by a `use` prefix on the called name. A hook a render skips
7
+ // shifts every later hook by one slot, and production React does not check the order — a later `useRef` then
8
+ // receives a frozen store value and the screen dies far from the cause ("Cannot add property current, object is not
9
+ // extensible").
9
10
  //
10
- // Reported: the right operand of `||` / `&&` / `??`, a ternary branch, an `if` / `else` body, a `switch` case, a
11
- // loop, a `catch` block, and a callback handed to a call or a JSX prop inside a component (`.map`, `useEffect`,
12
- // an event handler, a render prop — `Load.Units` calls `renderList` only while the list is non-empty). A test
13
- // position is not reported: `if (st.use.x())` always runs.
11
+ // Reported: the right operand of `||` / `&&` / `??` and of `||=` / `&&=` / `??=`, a ternary branch, an `if` / `else`
12
+ // body, a `switch` case, a loop (a `for` initializer runs once and is not one), a `catch` block, a default value,
13
+ // the arguments of an optional call, and a callback handed to a call or a JSX prop inside a component, inline or by
14
+ // a camelCase name (`.map`, `useEffect`, an event handler, a render prop — `Load.Units` calls `renderList` only while
15
+ // the list is non-empty). A test position is not reported: `if (st.use.x())` always runs. Each position counts up to
16
+ // the nearest function only, so a component defined in `useMemo` or picked by a module-level ternary is its own
17
+ // render; a PascalCase name is a component, which React renders rather than calls.
14
18
  //
15
19
  // Not reported: a hook after an early `return` (GritQL sees no statement order), a local helper the component
16
20
  // calls (`const tool = (name) => st.tool(name)…exec(…)` runs a fixed number of times when called at top level),
@@ -25,7 +29,15 @@ pattern st_hook_call() {
25
29
  `$chain.exec($args)` where { $chain <: r"st\s*\.\s*tool\s*\([\s\S]*" },
26
30
  `$chain.card($args)` where { $chain <: r"st\s*\.\s*tool\s*\([\s\S]*" },
27
31
  `$chain.init($args)` where { $chain <: r"st\s*\.\s*useState\s*\([\s\S]*" },
28
- `$chain.value($args)` where { $chain <: r"st\s*\.\s*expose\s*\([\s\S]*" }
32
+ `$chain.value($args)` where { $chain <: r"st\s*\.\s*expose\s*\([\s\S]*" },
33
+ // Computed access (`st["use"].x()`, `st.use[key]()`) at any step, which would take a snippet per combination.
34
+ JsCallExpression(callee = $callee) where {
35
+ $callee <: or {
36
+ r"st\s*(?:\.\s*use|\[\s*[\x27\x22`]use[\x27\x22`]\s*\])\s*(?:\.\s*[\w$]+|\[[^\]]*\])",
37
+ r"st\s*(?:\.\s*slice|\[\s*[\x27\x22`]slice[\x27\x22`]\s*\])\s*(?:\.\s*[\w$]+|\[[^\]]*\])\s*(?:\.\s*use|\[\s*[\x27\x22`]use[\x27\x22`]\s*\])\s*(?:\.\s*[\w$]+|\[[^\]]*\])",
38
+ r"st\s*\[\s*[\x27\x22`](?:sel|ref)[\x27\x22`]\s*\]"
39
+ }
40
+ }
29
41
  }
30
42
  }
31
43
 
@@ -34,36 +46,95 @@ pattern function_like() {
34
46
  JsArrowFunctionExpression(),
35
47
  JsFunctionExpression(),
36
48
  JsFunctionDeclaration(),
49
+ JsFunctionExportDefaultDeclaration(),
37
50
  JsMethodObjectMember(),
38
- JsMethodClassMember()
51
+ JsGetterObjectMember(),
52
+ JsSetterObjectMember(),
53
+ JsMethodClassMember(),
54
+ JsGetterClassMember(),
55
+ JsSetterClassMember(),
56
+ JsConstructorClassMember()
57
+ }
58
+ }
59
+
60
+ // An IIFE runs where it stands, so the position around it still decides whether its hooks run.
61
+ pattern function_boundary() {
62
+ function_like() as $boundary where {
63
+ not $boundary <: within JsCallExpression(callee = $iifeCallee) where {
64
+ $iifeCallee <: or { $boundary, JsParenthesizedExpression(expression = $boundary) }
65
+ }
66
+ }
67
+ }
68
+
69
+ pattern callback_slot() {
70
+ or { JsCallArguments(), JsxExpressionAttributeValue(), JsxExpressionChild() }
71
+ }
72
+
73
+ pattern passed_by_name($name) {
74
+ or {
75
+ JsCallArguments(args = [..., $arg where { $arg <: $name }, ...]),
76
+ JsxExpressionAttributeValue(expression = $arg) where { $arg <: $name },
77
+ JsxExpressionChild(expression = $arg) where { $arg <: $name }
39
78
  }
40
79
  }
41
80
 
42
81
  JsCallExpression() as $call where {
43
82
  $call <: st_hook_call(),
44
83
  or {
45
- $call <: within JsLogicalExpression(right = $right) where { $right <: contains $call },
84
+ $call <: within JsLogicalExpression(right = $right) where { $right <: contains $call } until function_boundary(),
85
+ // The operator token is not a field GritQL can read; `[^=]*` makes the first `=` in the text the operator's.
86
+ $call <: within JsAssignmentExpression(right = $right) as $assignment where {
87
+ $assignment <: r"[^=]*(?:\|\||&&|\?\?)=[\s\S]*",
88
+ $right <: contains $call
89
+ } until function_boundary(),
46
90
  $call <: within JsConditionalExpression(consequent = $then, alternate = $else) where {
47
91
  or { $then <: contains $call, $else <: contains $call }
48
- },
92
+ } until function_boundary(),
49
93
  $call <: within JsIfStatement(consequent = $then, else_clause = $else) where {
50
94
  or { $then <: contains $call, $else <: contains $call }
51
- },
52
- $call <: within JsForOfStatement(body = $body) where { $body <: contains $call },
53
- $call <: within JsForInStatement(body = $body) where { $body <: contains $call },
95
+ } until function_boundary(),
96
+ $call <: within JsForStatement(initializer = $init) where { not $init <: contains $call } until function_boundary(),
97
+ $call <: within JsForOfStatement(body = $body) where { $body <: contains $call } until function_boundary(),
98
+ $call <: within JsForInStatement(body = $body) where { $body <: contains $call } until function_boundary(),
54
99
  $call <: within or {
55
100
  JsCaseClause(),
56
101
  JsDefaultClause(),
57
- JsForStatement(),
58
102
  JsWhileStatement(),
59
103
  JsDoWhileStatement(),
60
104
  JsCatchClause()
61
- },
62
- // The callback must be handed over inside another function: a component passed to `memo()` / `forwardRef()`
63
- // at module level is not one.
64
- $call <: within function_like() as $inner where {
65
- $inner <: within or { JsCallArguments(), JsxExpressionAttributeValue(), JsxExpressionChild() } as $slot where {
66
- $slot <: within function_like()
105
+ } until function_boundary(),
106
+ $call <: within or {
107
+ JsFormalParameter(initializer = $default),
108
+ JsObjectBindingPatternShorthandProperty(init = $default),
109
+ JsObjectBindingPatternProperty(init = $default),
110
+ JsArrayBindingPatternElement(init = $default),
111
+ JsObjectAssignmentPatternShorthandProperty(init = $default),
112
+ JsObjectAssignmentPatternProperty(init = $default),
113
+ JsArrayAssignmentPatternElement(init = $default)
114
+ } where { $default <: contains $call } until function_boundary(),
115
+ // A `?.` ahead of the callee's first `(` or `[` sits on its own chain, so a nullish link skips the arguments.
116
+ $call <: within JsCallExpression(callee = $callee, arguments = $arguments) as $outerCall where {
117
+ $arguments <: contains $call,
118
+ or { $outerCall <: `$optionalCallee?.($optionalArgs)`, $callee <: r"[^\[\(]*\?\.[\s\S]*" }
119
+ } until function_boundary(),
120
+ and {
121
+ $call <: within function_boundary() as $nearest until function_boundary(),
122
+ or {
123
+ // The callback must be handed over inside another function: a component passed to `memo()` /
124
+ // `forwardRef()` at module level is not one.
125
+ $nearest <: within callback_slot() as $slot where { $slot <: within function_boundary() } until function_boundary(),
126
+ and {
127
+ or {
128
+ $nearest <: JsFunctionDeclaration(id = $name),
129
+ $nearest <: within JsVariableDeclarator(id = $name, initializer = JsInitializerClause(expression = $value)) where {
130
+ $value <: $nearest
131
+ } until function_boundary()
132
+ },
133
+ $name <: r"[a-z_$][\w$]*",
134
+ $nearest <: within or { JsFunctionBody(), JsModule() } as $scope where {
135
+ $scope <: contains passed_by_name($name) as $handover where { $handover <: within function_boundary() }
136
+ } until or { JsFunctionBody(), JsModule() }
137
+ }
67
138
  }
68
139
  }
69
140
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.1.0-rc.25",
3
+ "version": "3.1.0-rc.27",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -48,7 +48,7 @@
48
48
  "@earendil-works/pi-tui": "1.3.0",
49
49
  "@inquirer/prompts": "^8.7.3",
50
50
  "@tailwindcss/node": "^4.3.3",
51
- "akanjs": "3.1.0-rc.25",
51
+ "akanjs": "3.1.0-rc.27",
52
52
  "chalk": "^6.0.1",
53
53
  "commander": "^15.0.0",
54
54
  "dayjs": "^1.11.23",
package/subspace.test.ts CHANGED
@@ -245,7 +245,7 @@ describe("Subspace push", () => {
245
245
  expect(second.sha).not.toBe(first.sha);
246
246
  });
247
247
 
248
- test("keeps the subspace's own root config and drops a Jenkinsfile an earlier push left there", async () => {
248
+ test("keeps the subspace's own root config and a Jenkinsfile an earlier push left there, never the workspace's", async () => {
249
249
  const { workRoot, bareRoot, subspace } = await makeMirror("own-served", "own-private", "own-kit");
250
250
  const customer = await cloneSubspace(workRoot, bareRoot, "own-customer");
251
251
  await write(path.join(customer, "akan.config.ts"), "export default { pipeline: {} };\n");
@@ -261,7 +261,8 @@ describe("Subspace push", () => {
261
261
  expect(await FileSys.readText(path.join(subspaceRoot, "akan.config.ts"))).toContain(
262
262
  "export default { pipeline: {} };",
263
263
  );
264
- expect(await FileSys.fileExists(path.join(subspaceRoot, "Jenkinsfile"))).toBe(false);
264
+ expect(await FileSys.fileExists(path.join(subspaceRoot, "Jenkinsfile"))).toBe(true);
265
+ expect(await FileSys.readText(path.join(subspaceRoot, "Jenkinsfile"))).toContain("pipeline { agent none }");
265
266
  });
266
267
 
267
268
  test("refuses a lock that resolves from a registry only this machine reaches, before it clones", async () => {
package/subspaceClone.ts CHANGED
@@ -47,10 +47,6 @@ export class SubspaceClone {
47
47
  this.#clone = new Executor(`subspace-${declaration.name}`, this.#clonePath);
48
48
  }
49
49
 
50
- async #cloneGit(args: string[]) {
51
- return await this.#workspace.spawn("git", ["-C", this.#clonePath, ...args]);
52
- }
53
-
54
50
  async #ensure(branch: string) {
55
51
  if (!(await FileSys.dirExists(path.join(this.#clonePath, ".git")))) {
56
52
  await rm(this.#clonePath, { recursive: true, force: true });
@@ -160,15 +156,13 @@ export class SubspaceClone {
160
156
  await rm(path.join(this.#clonePath, SubspaceClone.holdDir), { recursive: true, force: true });
161
157
  }
162
158
 
163
- //* A root entry last written by a sync commit is a copy an earlier push left there, the parent's Jenkinsfile among them:
164
- //* it goes. Any other is the subspace's own and is held through the extraction.
159
+ //* Held even when an earlier push left it, the workspace's Jenkinsfile among them: it is the subspace's only copy.
165
160
  async #holdRootEntries() {
166
161
  const holdRoot = path.join(this.#clonePath, SubspaceClone.holdDir, "root");
167
162
  const held: string[] = [];
168
163
  for (const entry of SubspaceClone.subspaceOwnedRootEntries) {
169
164
  const from = path.join(this.#clonePath, entry);
170
165
  if (!(await FileSys.entryExists(from))) continue;
171
- if (await this.#isPushedCopy(entry)) continue;
172
166
  await mkdir(holdRoot, { recursive: true });
173
167
  await rename(from, path.join(holdRoot, entry));
174
168
  held.push(entry);
@@ -185,11 +179,6 @@ export class SubspaceClone {
185
179
  }
186
180
  }
187
181
 
188
- async #isPushedCopy(entry: string) {
189
- const subject = await this.#cloneGit(["log", "-1", "--format=%s", "--", entry]).catch(() => "");
190
- return subject.trim().startsWith(SubspaceClone.syncSubjectPrefix);
191
- }
192
-
193
182
  static async #copyMissing(from: string, to: string) {
194
183
  await mkdir(to, { recursive: true });
195
184
  for (const entry of await readdir(from, { withFileTypes: true })) {