@akanjs/devkit 3.1.2-rc.3 → 3.2.0-rc.0

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 (93) hide show
  1. package/CHANGELOG.md +348 -0
  2. package/abstractDoc.ts +1 -1
  3. package/agentAssets.ts +2 -2
  4. package/akanConfig/akanConfig.ts +2 -2
  5. package/akanMcpContract.ts +2 -2
  6. package/apiReference.test.ts +37 -0
  7. package/apiReference.ts +240 -0
  8. package/applicationBuildReporter.test.ts +54 -0
  9. package/applicationBuildReporter.ts +12 -0
  10. package/applicationBuildRunner.test.ts +34 -14
  11. package/applicationBuildRunner.ts +77 -14
  12. package/artifact/implicitRootLayout.test.ts +42 -0
  13. package/artifact/implicitRootLayout.ts +5 -6
  14. package/artifact/storeBootstrap.ts +24 -0
  15. package/cloud/cloudApi.test.ts +70 -7
  16. package/cloud/cloudApi.ts +10 -4
  17. package/cloud/constants.ts +3 -0
  18. package/cloud/globalConfig.test.ts +36 -0
  19. package/cloud/globalConfig.ts +10 -1
  20. package/codeAgent/skills/akan-debug/SKILL.md +2 -1
  21. package/codeAgent/skills/akan-page/SKILL.md +30 -2
  22. package/codeAgent/skills/akan-service/SKILL.md +77 -1
  23. package/commandDecorators/command.ts +55 -9
  24. package/commandDecorators/commandDecorators.test.ts +131 -2
  25. package/declarationReader.ts +211 -0
  26. package/executors.test.ts +65 -8
  27. package/executors.ts +5 -3
  28. package/frameworkGraph.test.ts +121 -0
  29. package/frameworkGraph.ts +165 -0
  30. package/frontendBuild/allRoutesBuilder.test.ts +84 -0
  31. package/frontendBuild/allRoutesBuilder.ts +32 -12
  32. package/frontendBuild/buildRouteClient.test.ts +192 -2
  33. package/frontendBuild/bundleDefine.ts +4 -0
  34. package/frontendBuild/clientBuildTypes.ts +7 -1
  35. package/frontendBuild/clientEntriesBundler.test.ts +82 -0
  36. package/frontendBuild/clientEntriesBundler.ts +55 -4
  37. package/frontendBuild/csrArtifactBuilder.test.ts +23 -0
  38. package/frontendBuild/csrArtifactBuilder.ts +2 -1
  39. package/frontendBuild/fontOptimizer.test.ts +54 -0
  40. package/frontendBuild/fontOptimizer.ts +5 -4
  41. package/frontendBuild/index.ts +3 -0
  42. package/frontendBuild/routeClientBuilder.ts +6 -2
  43. package/frontendBuild/routeModulePreloads.test.ts +59 -0
  44. package/frontendBuild/routeModulePreloads.ts +52 -0
  45. package/frontendBuild/ssrBaseArtifactBuilder.ts +35 -10
  46. package/frontendBuild/ssrDevBundler.test.ts +26 -0
  47. package/frontendBuild/ssrDevBundler.ts +2 -1
  48. package/frontendBuild/vendorImportScanner.test.ts +71 -0
  49. package/frontendBuild/vendorImportScanner.ts +80 -0
  50. package/frontendBuild/vendorNarrowing.test.ts +103 -0
  51. package/frontendBuild/vendorNarrowing.ts +64 -0
  52. package/frontendBuild/vendorSpecifiers.ts +13 -0
  53. package/gitRevision.test.ts +82 -0
  54. package/gitRevision.ts +128 -0
  55. package/incrementalBuilder/builderGraphPayloads.ts +60 -0
  56. package/incrementalBuilder/incrementalBuilder.proc.test.ts +105 -6
  57. package/incrementalBuilder/incrementalBuilder.proc.ts +6 -10
  58. package/integration/devStability.integration.test.ts +1 -1
  59. package/integration/devStabilityHarness.test.ts +1 -1
  60. package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +4 -2
  61. package/lint/__fixtures__/no-js-private-class-method/bad.tsx +2 -0
  62. package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -2
  63. package/lint/no-daisyui-legacy-class.grit +9 -7
  64. package/lint/no-deep-internal-import.grit +1 -1
  65. package/lint/no-js-private-class-method.grit +9 -28
  66. package/maintainabilityBaseline.ts +7 -2
  67. package/maintainabilityContract.test.ts +183 -0
  68. package/maintainabilityContract.ts +145 -30
  69. package/maintainabilityExceptionScanner.test.ts +13 -2
  70. package/maintainabilityExceptionScanner.ts +16 -6
  71. package/maintainabilityLimits.ts +12 -0
  72. package/maintainabilityReport.test.ts +35 -0
  73. package/maintainabilityReport.ts +29 -8
  74. package/mobile/nativePublicEnv.test.ts +5 -1
  75. package/package.json +3 -3
  76. package/routeJsBudget.test.ts +177 -0
  77. package/routeJsBudget.ts +222 -0
  78. package/structureScanner.test.ts +167 -0
  79. package/structureScanner.ts +164 -0
  80. package/transforms/barrelAnalyzer.ts +10 -3
  81. package/transforms/barrelImportsPlugin.ts +13 -7
  82. package/transforms/serverEnvPlugin.test.ts +1 -1
  83. package/transforms/serverEnvPlugin.ts +1 -1
  84. package/transforms/transforms.test.ts +64 -12
  85. package/transforms/tsconfigPackageResolver.test.ts +12 -0
  86. package/typeChecker.test.ts +100 -0
  87. package/typeChecker.ts +14 -2
  88. package/workflow/plan.ts +79 -17
  89. package/workflow/types.ts +1 -0
  90. package/workspaceGraph.test.ts +121 -0
  91. package/workspaceGraph.ts +149 -16
  92. package/workspaceLayout.ts +1 -0
  93. /package/integration/{devStabilityHarness.ts → devStabilityHarness.fixture.ts} +0 -0
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
  import path from "node:path";
3
- import { AppExecutor, LibExecutor, WorkspaceExecutor } from "../executors";
3
+ import { AppExecutor, LibExecutor, PkgExecutor, WorkspaceExecutor } from "../executors";
4
4
  import { makeCliTempWorkspace, tempRoots, writeText } from "../testHelpers";
5
5
  import {
6
6
  App,
@@ -15,7 +15,13 @@ import {
15
15
  SysOrOnlyApp,
16
16
  Workspace,
17
17
  } from "./argMeta";
18
- import { getAppsArgumentValue, getArgumentValue, getInternalArgumentValue, getOptionValue } from "./command";
18
+ import {
19
+ getAppsArgumentValue,
20
+ getArgumentValue,
21
+ getInternalArgumentValue,
22
+ getOptionValue,
23
+ rerunCommandOf,
24
+ } from "./command";
19
25
  import { command } from "./commandBuilder";
20
26
  import {
21
27
  assertUniqueDependencies,
@@ -499,6 +505,129 @@ describe("a Sys that defaults to the only app", () => {
499
505
  expect(app instanceof AppExecutor ? app.name : null).toBe("admin");
500
506
  expect(asked).toEqual([["web", "admin", "util"]]);
501
507
  });
508
+
509
+ test("a terminal is still asked when the workspace holds a single app or lib", async () => {
510
+ setTTY(true);
511
+ answer = "web";
512
+ const workspace = workspaceWith([]);
513
+ workspace.getExecs = async () => [["web"], [], []];
514
+ const app = await getInternalArgumentValue(sysMetaOf("sync"), undefined, workspace);
515
+ expect(app instanceof AppExecutor ? app.name : null).toBe("web");
516
+ expect(asked).toEqual([["web"]]);
517
+ });
518
+
519
+ test("without a terminal, an Exec takes the workspace's only app, lib or pkg", async () => {
520
+ setTTY(undefined);
521
+ const workspace = workspaceWith([]);
522
+ workspace.getExecs = async () => [[], [], ["tools"]];
523
+ const pkg = await getInternalArgumentValue({ key: "", idx: 0, type: "Exec" }, undefined, workspace);
524
+ expect(pkg instanceof PkgExecutor ? pkg.name : null).toBe("tools");
525
+ expect(asked).toEqual([]);
526
+ });
527
+
528
+ test("without a terminal, the only lib, pkg or module is not taken, as a command may be removing it", async () => {
529
+ setTTY(undefined);
530
+ const workspace = workspaceWith([]);
531
+ workspace.getExecs = async () => [[], ["util"], []];
532
+ workspace.getPkgs = async () => ["tools"];
533
+ const rerunOf = (name: string) => rerunCommandOf([name], [undefined], 0);
534
+ await expect(
535
+ getInternalArgumentValue({ key: "", idx: 0, type: "Lib" }, undefined, workspace, rerunOf("remove-library")),
536
+ ).rejects.toThrow("so rerun it as `akan remove-library <lib>` (choices: util).");
537
+ await expect(
538
+ getInternalArgumentValue({ key: "", idx: 0, type: "Pkg" }, undefined, workspace, rerunOf("remove-package")),
539
+ ).rejects.toThrow("so rerun it as `akan remove-package <pkg>` (choices: tools).");
540
+ expect(asked).toEqual([]);
541
+ });
542
+
543
+ test("without a terminal, a named target the workspace lacks is refused, not swapped for the only one", async () => {
544
+ setTTY(undefined);
545
+ const workspace = workspaceWith([]);
546
+ workspace.getExecs = async () => [["web"], [], []];
547
+ await expect(
548
+ getInternalArgumentValue(sysMetaOf("sync"), "webb", workspace, rerunCommandOf(["sync", "webb"], ["webb"], 0)),
549
+ ).rejects.toThrow(
550
+ "Unknown app or lib 'webb': stdin is not a terminal to prompt on, so rerun it as `akan sync <app-or-lib>` (choices: web).",
551
+ );
552
+ expect(asked).toEqual([]);
553
+ });
554
+ });
555
+
556
+ describe("the line a refused target is rerun with", () => {
557
+ test("puts the target in its operand slot, after the operands before it, keeping every option", () => {
558
+ expect(rerunCommandOf(["test", "--write", "false"], [undefined, undefined], 0)("<t>")).toBe(
559
+ "akan test <t> --write false",
560
+ );
561
+ expect(rerunCommandOf(["create-module", "--page", "true", "photo"], ["photo", undefined], 1)("web")).toBe(
562
+ "akan create-module --page true photo web",
563
+ );
564
+ expect(rerunCommandOf(["build", "--env", "it's main"], [undefined], 0)("web")).toBe(
565
+ "akan build web --env 'it'\\''s main'",
566
+ );
567
+ });
568
+
569
+ test("takes the place of a target that was given but is not in the workspace", () => {
570
+ expect(rerunCommandOf(["sync", "nope"], ["nope"], 0)("<app-or-lib>")).toBe("akan sync <app-or-lib>");
571
+ });
572
+ });
573
+
574
+ describe("a target left out where no terminal can ask for it", () => {
575
+ const track = tempRoots();
576
+ const run = async (apps: string[]) => {
577
+ const { root } = track(await makeCliTempWorkspace());
578
+ await writeText(path.join(root, "package.json"), '{ "name": "repo", "version": "1.0.0" }\n');
579
+ await writeText(path.join(root, "tsconfig.json"), "{}\n");
580
+ for (const app of apps) await writeText(path.join(root, "apps", app, "akan.config.ts"), "export default {};\n");
581
+ const entry = path.join(root, "cli.ts");
582
+ await writeText(
583
+ entry,
584
+ [
585
+ `import { command, runCommands, Sys } from ${JSON.stringify(path.join(import.meta.dir, "index.ts"))};`,
586
+ 'class ThingCommand extends command("thing", ({ public: target }) => ({',
587
+ ' createThing: target({ desc: "creates a thing" })',
588
+ ' .arg("thing", String, { desc: "name of the thing" })',
589
+ " .with(Sys)",
590
+ ' .option("force", Boolean, { default: false })',
591
+ " .exec(async function (thing, sys, force) {",
592
+ ' process.stdout.write(thing + " in " + sys.name + ", force " + force + "\\n");',
593
+ " }),",
594
+ "})) {}",
595
+ "await runCommands(ThingCommand);",
596
+ "",
597
+ ].join("\n"),
598
+ );
599
+ const proc = Bun.spawn(["bun", entry, "create-thing", "photo", "--force"], {
600
+ cwd: root,
601
+ stdin: "ignore",
602
+ stdout: "pipe",
603
+ stderr: "pipe",
604
+ });
605
+ const [stdout, stderr, code] = await Promise.all([
606
+ new Response(proc.stdout).text(),
607
+ new Response(proc.stderr).text(),
608
+ proc.exited,
609
+ ]);
610
+ return { stdout, stderr, code };
611
+ };
612
+
613
+ test("runs on the workspace's only app or lib, naming it on stderr", async () => {
614
+ const ran = await run(["web"]);
615
+ expect(ran.code).toBe(0);
616
+ expect(ran.stdout).toContain("photo in web, force true\n");
617
+ expect(ran.stderr).toContain(
618
+ "No app or lib named, so this runs on 'web', the only one in this workspace: akan create-thing photo web --force",
619
+ );
620
+ }, 30_000);
621
+
622
+ test("exits at once with the choices and the line to rerun when there are several", async () => {
623
+ const refused = await run(["web", "admin"]);
624
+ expect(refused.code).toBe(1);
625
+ expect(refused.stdout).toContain(
626
+ "Missing app or lib: stdin is not a terminal to prompt on, so rerun it as `akan create-thing photo <app-or-lib> --force`",
627
+ );
628
+ expect(refused.stdout).toMatch(/\(choices: (web, admin|admin, web)\)\./);
629
+ expect(refused.stdout).not.toContain("photo in");
630
+ }, 30_000);
502
631
  });
503
632
 
504
633
  describe("the exit code of a command that failed", () => {
@@ -0,0 +1,211 @@
1
+ import path from "node:path";
2
+ import ts from "typescript";
3
+
4
+ export interface MemberFilter {
5
+ only?: string[];
6
+ omit?: string[];
7
+ prefix?: string;
8
+ }
9
+
10
+ const nodeFlags =
11
+ ts.NodeBuilderFlags.NoTruncation |
12
+ ts.NodeBuilderFlags.UseAliasDefinedOutsideCurrentScope |
13
+ ts.NodeBuilderFlags.OmitParameterModifiers;
14
+ const typeFlags = ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope;
15
+
16
+ export class DeclarationReader {
17
+ static readonly #printer = ts.createPrinter({ removeComments: true, omitTrailingSemicolon: true });
18
+ readonly #dir: string;
19
+ readonly #program: ts.Program;
20
+ readonly #checker: ts.TypeChecker;
21
+ readonly #entries: Map<string, string>;
22
+ readonly #scratch: ts.SourceFile;
23
+
24
+ constructor(akanjsDir: string) {
25
+ this.#dir = akanjsDir;
26
+ const configPath = path.join(akanjsDir, "tsconfig.json");
27
+ const config = ts.readConfigFile(configPath, ts.sys.readFile);
28
+ const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, akanjsDir, undefined, configPath);
29
+ this.#entries = DeclarationReader.#entryFiles(akanjsDir);
30
+ this.#program = ts.createProgram({
31
+ rootNames: [...new Set(this.#entries.values())],
32
+ options: { ...parsed.options, noEmit: true },
33
+ });
34
+ this.#checker = this.#program.getTypeChecker();
35
+ this.#scratch = ts.createSourceFile("reference.ts", "", ts.ScriptTarget.Latest);
36
+ }
37
+
38
+ static #entryFiles(akanjsDir: string) {
39
+ const manifest = JSON.parse(ts.sys.readFile(path.join(akanjsDir, "package.json")) ?? "{}") as {
40
+ exports: Record<string, unknown>;
41
+ };
42
+ const entries = new Map<string, string>();
43
+ for (const [key, value] of Object.entries(manifest.exports)) {
44
+ const target = typeof value === "string" ? value : (value as { types?: string; import?: string }).types;
45
+ if (key.includes("*") || typeof target !== "string" || !/\.tsx?$/.test(target)) continue;
46
+ entries.set(key === "." ? "akanjs" : `akanjs/${key.slice(2)}`, path.join(akanjsDir, target));
47
+ }
48
+ return entries;
49
+ }
50
+
51
+ exported(from: string, name: string): ts.Symbol {
52
+ const file = this.#entries.get(from);
53
+ const source = file ? this.#program.getSourceFile(file) : undefined;
54
+ const module = source ? this.#checker.getSymbolAtLocation(source) : undefined;
55
+ const symbol = module ? this.#checker.getExportsOfModule(module).find((each) => each.name === name) : undefined;
56
+ if (!symbol) throw new Error(`${name} is not exported from ${from}: update the API reference manifest`);
57
+ return symbol.flags & ts.SymbolFlags.Alias ? this.#checker.getAliasedSymbol(symbol) : symbol;
58
+ }
59
+
60
+ declared(file: string, name: string): ts.Symbol {
61
+ const source = this.#program.getSourceFile(path.join(this.#dir, file));
62
+ const statement = source?.statements.find(
63
+ (each) =>
64
+ (ts.isClassDeclaration(each) || ts.isInterfaceDeclaration(each) || ts.isTypeAliasDeclaration(each)) &&
65
+ each.name?.text === name,
66
+ ) as ts.ClassDeclaration | ts.InterfaceDeclaration | ts.TypeAliasDeclaration | undefined;
67
+ const symbol = statement?.name ? this.#checker.getSymbolAtLocation(statement.name) : undefined;
68
+ if (!symbol) throw new Error(`${name} is not declared in ${file}: update the API reference manifest`);
69
+ return symbol;
70
+ }
71
+
72
+ declaredType(symbol: ts.Symbol): ts.Type {
73
+ return this.#checker.getDeclaredTypeOfSymbol(symbol);
74
+ }
75
+
76
+ instanceType(symbol: ts.Symbol): ts.Type {
77
+ if (symbol.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Interface | ts.SymbolFlags.TypeAlias))
78
+ return this.declaredType(symbol);
79
+ const type = this.#checker.getTypeOfSymbol(symbol);
80
+ const [signature] = type.getCallSignatures();
81
+ return signature ? signature.getReturnType() : type;
82
+ }
83
+
84
+ declarationText(symbol: ts.Symbol): string {
85
+ const [declaration] = symbol.declarations ?? [];
86
+ if (!declaration) throw new Error(`${symbol.name} has no declaration`);
87
+ return DeclarationReader.#print(declaration, declaration.getSourceFile()).replace(/^export /, "");
88
+ }
89
+
90
+ signatures(symbol: ts.Symbol, name: string): string[] {
91
+ const type = this.#checker.getTypeOfSymbol(symbol);
92
+ const signatures = type.getCallSignatures();
93
+ if (signatures.length === 0) throw new Error(`${name} is not callable`);
94
+ return signatures.map((signature) => `${name}${this.#checker.signatureToString(signature, undefined, typeFlags)}`);
95
+ }
96
+
97
+ props(type: ts.Type, omit: string[] = []): string[] {
98
+ const lines: string[] = [];
99
+ const properties = this.#checker.getPropertiesOfType(type);
100
+ DeclarationReader.#assertNamed(properties, omit);
101
+ for (const property of properties) {
102
+ if (omit.includes(property.name) || DeclarationReader.#isInternalName(property.name)) continue;
103
+ const [declaration] = property.declarations ?? [];
104
+ if (declaration && !this.#isOwn(declaration)) continue;
105
+ const optional = property.flags & ts.SymbolFlags.Optional ? "?" : "";
106
+ const propertyType = declaration
107
+ ? this.#checker.getTypeOfSymbolAtLocation(property, declaration)
108
+ : this.#checker.getTypeOfSymbol(property);
109
+ const shown = this.#checker.typeToString(
110
+ optional ? this.#checker.getNonNullableType(propertyType) : propertyType,
111
+ undefined,
112
+ typeFlags,
113
+ );
114
+ const doc = this.#firstSentence(property);
115
+ if (doc) lines.push(`/** ${doc} */`);
116
+ lines.push(`${property.name}${optional}: ${shown};`);
117
+ }
118
+ return lines;
119
+ }
120
+
121
+ members(type: ts.Type, { only, omit = [], prefix = "." }: MemberFilter): [string, string][] {
122
+ const properties = this.#checker.getPropertiesOfType(type);
123
+ DeclarationReader.#assertNamed(properties, [...(only ?? []), ...omit]);
124
+ const found: [string, string][] = [];
125
+ for (const property of properties) {
126
+ const name = property.name;
127
+ if ((only && !only.includes(name)) || omit.includes(name) || DeclarationReader.#isInternalName(name)) continue;
128
+ const [declaration] = property.declarations ?? [];
129
+ if (!declaration || DeclarationReader.#isHidden(declaration)) continue;
130
+ const signatures = this.#checker.getTypeOfSymbolAtLocation(property, declaration).getCallSignatures();
131
+ for (const signature of signatures)
132
+ found.push([name, `${prefix}${name}${this.#withoutReturn(signature, declaration)}`]);
133
+ }
134
+ return found;
135
+ }
136
+
137
+ static #assertNamed(properties: ts.Symbol[], names: string[]) {
138
+ for (const name of names)
139
+ if (!properties.some((property) => property.name === name))
140
+ throw new Error(`${name} is not a member any more: update the API reference manifest`);
141
+ }
142
+
143
+ static #isInternalName(name: string) {
144
+ return name.startsWith("_") || name.startsWith("#");
145
+ }
146
+
147
+ #isOwn(declaration: ts.Declaration) {
148
+ return !path.relative(this.#dir, declaration.getSourceFile().fileName).startsWith("..");
149
+ }
150
+
151
+ static #isHidden(declaration: ts.Declaration) {
152
+ const flags = ts.getCombinedModifierFlags(declaration);
153
+ return (
154
+ (flags & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected | ts.ModifierFlags.Static)) !== 0 ||
155
+ (ts.isPropertyDeclaration(declaration) && !declaration.type && !declaration.initializer) ||
156
+ ts.isConstructorDeclaration(declaration)
157
+ );
158
+ }
159
+
160
+ #withoutReturn(signature: ts.Signature, enclosing: ts.Declaration) {
161
+ const node = this.#checker.signatureToSignatureDeclaration(
162
+ signature,
163
+ ts.SyntaxKind.MethodSignature,
164
+ enclosing,
165
+ nodeFlags,
166
+ ) as ts.MethodSignature | undefined;
167
+ if (!node) throw new Error(`${this.#checker.signatureToString(signature)} cannot be printed`);
168
+ const typeParameters = node.typeParameters?.filter((each) => !each.name.text.startsWith("_"));
169
+ const parameters = node.parameters.map((parameter) =>
170
+ parameter.questionToken && parameter.type && ts.isUnionTypeNode(parameter.type)
171
+ ? ts.factory.updateParameterDeclaration(
172
+ parameter,
173
+ parameter.modifiers,
174
+ parameter.dotDotDotToken,
175
+ parameter.name,
176
+ parameter.questionToken,
177
+ DeclarationReader.#withoutUndefined(parameter.type),
178
+ parameter.initializer,
179
+ )
180
+ : parameter,
181
+ );
182
+ const stripped = ts.factory.createMethodSignature(
183
+ undefined,
184
+ "x",
185
+ undefined,
186
+ typeParameters?.length ? typeParameters : undefined,
187
+ parameters,
188
+ undefined,
189
+ );
190
+ return DeclarationReader.#print(stripped, this.#scratch)
191
+ .replace(/^x/, "")
192
+ .replace(/;$/, "")
193
+ .replaceAll(" & Record<never, never>", "");
194
+ }
195
+
196
+ static #print(node: ts.Node, source: ts.SourceFile) {
197
+ return DeclarationReader.#printer.printNode(ts.EmitHint.Unspecified, node, source).replace(/\s+/g, " ");
198
+ }
199
+
200
+ static #withoutUndefined(type: ts.UnionTypeNode): ts.TypeNode {
201
+ const kept = type.types.filter((each) => each.kind !== ts.SyntaxKind.UndefinedKeyword);
202
+ return kept.length === 1 ? kept[0] : ts.factory.createUnionTypeNode(kept);
203
+ }
204
+
205
+ #firstSentence(symbol: ts.Symbol) {
206
+ const text = ts.displayPartsToString(symbol.getDocumentationComment(this.#checker)).replace(/\s+/g, " ").trim();
207
+ if (!text) return "";
208
+ const end = text.search(/\.(\s|$)/);
209
+ return (end === -1 ? text : text.slice(0, end + 1)).replace(/\*\//g, "*\\/");
210
+ }
211
+ }
package/executors.test.ts CHANGED
@@ -304,8 +304,8 @@ describe("Executor filesystem helpers", () => {
304
304
 
305
305
  await exec.applyTemplate({ basePath: "app", template: "app", dict: { appName: "demo" }, options: { libs: [] } });
306
306
  const templateRoot = path.resolve(import.meta.dir, "../cli/templates/app/public");
307
- await expect(readFile(path.join(root, "app/public/logo.png"))).resolves.toEqual(
308
- await readFile(path.join(templateRoot, "logo.png")),
307
+ await expect(readFile(path.join(root, "app/public/apple-touch-icon.png"))).resolves.toEqual(
308
+ await readFile(path.join(templateRoot, "apple-touch-icon.png")),
309
309
  );
310
310
  await expect(readFile(path.join(root, "app/public/favicon.ico"))).resolves.toEqual(
311
311
  await readFile(path.join(templateRoot, "favicon.ico")),
@@ -315,22 +315,32 @@ describe("Executor filesystem helpers", () => {
315
315
  test("keeps a static file already there when overwrite is false, and reports it unwritten", async () => {
316
316
  const root = await makeTempRoot();
317
317
  const exec = new Executor("fixture", root);
318
- await writeText(path.join(root, "app/public/logo.png"), "the app's own logo");
318
+ await writeText(path.join(root, "app/public/favicon.ico"), "the app's own icon");
319
319
 
320
320
  const applied = await exec.applyTemplate({
321
321
  basePath: "app/public",
322
- template: "app/public/logo.png",
322
+ template: "app/public/favicon.ico",
323
323
  overwrite: false,
324
324
  });
325
325
 
326
- expect(await readFile(path.join(root, "app/public/logo.png"), "utf8")).toBe("the app's own logo");
326
+ expect(await readFile(path.join(root, "app/public/favicon.ico"), "utf8")).toBe("the app's own icon");
327
327
  expect(applied).toEqual([
328
- expect.objectContaining({ filePath: path.join(root, "app/public/logo.png"), written: false }),
328
+ expect.objectContaining({ filePath: path.join(root, "app/public/favicon.ico"), written: false }),
329
329
  ]);
330
330
  });
331
331
  });
332
332
 
333
333
  describe("Workspace and app executor environment contracts", () => {
334
+ const baseDevEnvKeys = [
335
+ "AKAN_PUBLIC_APP_NAME",
336
+ "AKAN_WORKSPACE_ROOT",
337
+ "AKAN_WORKSPACE_ID",
338
+ "AKAN_PUBLIC_REPO_NAME",
339
+ "AKAN_PUBLIC_SERVE_DOMAIN",
340
+ "AKAN_PUBLIC_ENV",
341
+ "PORT_OFFSET",
342
+ ];
343
+
334
344
  test("reads base development environment", () => {
335
345
  process.env.AKAN_PUBLIC_REPO_NAME = "repo";
336
346
  process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
@@ -427,8 +437,7 @@ describe("Workspace and app executor environment contracts", () => {
427
437
  "",
428
438
  ].join("\n"),
429
439
  );
430
- delete process.env.AKAN_PUBLIC_REPO_NAME;
431
- delete process.env.AKAN_PUBLIC_SERVE_DOMAIN;
440
+ for (const key of baseDevEnvKeys) delete process.env[key];
432
441
 
433
442
  expect(WorkspaceExecutor.getBaseDevEnv(path.join(root, ".env"))).toEqual({
434
443
  appName: "file-app",
@@ -458,6 +467,7 @@ describe("Workspace and app executor environment contracts", () => {
458
467
  "",
459
468
  ].join("\r\n"),
460
469
  );
470
+ for (const key of baseDevEnvKeys) delete process.env[key];
461
471
 
462
472
  expect(WorkspaceExecutor.getBaseDevEnv(path.join(root, ".env"))).toEqual({
463
473
  appName: 'say \\"hi\\"\nagain',
@@ -471,6 +481,53 @@ describe("Workspace and app executor environment contracts", () => {
471
481
  });
472
482
  });
473
483
 
484
+ test("the shell outranks the workspace .env key by key, and a default stands in for what neither sets", async () => {
485
+ const root = await makeTempRoot();
486
+ await writeFile(
487
+ path.join(root, ".env"),
488
+ [
489
+ "AKAN_PUBLIC_ENV=local",
490
+ "AKAN_PUBLIC_REPO_NAME=file-repo",
491
+ "AKAN_PUBLIC_SERVE_DOMAIN=file.example.com",
492
+ "",
493
+ ].join("\n"),
494
+ );
495
+ for (const key of baseDevEnvKeys) delete process.env[key];
496
+ Object.assign(process.env, { AKAN_PUBLIC_ENV: "main", AKAN_PUBLIC_SERVE_DOMAIN: "shell.example.com" });
497
+
498
+ expect(WorkspaceExecutor.getBaseDevEnv(path.join(root, ".env"))).toMatchObject({
499
+ env: "main",
500
+ repoName: "file-repo",
501
+ serveDomain: "shell.example.com",
502
+ defaulted: [],
503
+ });
504
+
505
+ delete process.env.AKAN_PUBLIC_ENV;
506
+ await writeFile(path.join(root, ".env"), "AKAN_PUBLIC_ENV=local\n");
507
+ expect(WorkspaceExecutor.getBaseDevEnv(path.join(root, ".env"))).toMatchObject({
508
+ env: "local",
509
+ serveDomain: "shell.example.com",
510
+ defaulted: ["AKAN_PUBLIC_REPO_NAME"],
511
+ });
512
+ });
513
+
514
+ test("an empty export reads as unset, so the .env's value stands", async () => {
515
+ const root = await makeTempRoot();
516
+ await writeFile(
517
+ path.join(root, ".env"),
518
+ "AKAN_PUBLIC_ENV=main\nAKAN_PUBLIC_REPO_NAME=file-repo\nAKAN_PUBLIC_SERVE_DOMAIN=file.example.com\n",
519
+ );
520
+ for (const key of baseDevEnvKeys) delete process.env[key];
521
+ Object.assign(process.env, { AKAN_PUBLIC_ENV: "", AKAN_PUBLIC_REPO_NAME: "", AKAN_PUBLIC_SERVE_DOMAIN: "" });
522
+
523
+ expect(WorkspaceExecutor.getBaseDevEnv(path.join(root, ".env"))).toMatchObject({
524
+ env: "main",
525
+ repoName: "file-repo",
526
+ serveDomain: "file.example.com",
527
+ defaulted: [],
528
+ });
529
+ });
530
+
474
531
  test("builds app command environment and prepareCommand artifacts", async () => {
475
532
  const root = await makeTempRoot();
476
533
  process.env.AKAN_PUBLIC_REPO_NAME = "repo";
package/executors.ts CHANGED
@@ -156,7 +156,7 @@ const parseEnvValue = (value: string) => {
156
156
  return inner.replace(/\\(.)/g, (pair, char: string) => (char === "n" ? "\n" : char === "r" ? "\r" : pair));
157
157
  };
158
158
 
159
- const parseEnvFile = (envPath: string): Record<string, string> => {
159
+ export const parseEnvFile = (envPath: string): Record<string, string> => {
160
160
  const env: Record<string, string> = {};
161
161
  const content = (() => {
162
162
  try {
@@ -683,13 +683,15 @@ export class WorkspaceExecutor extends Executor {
683
683
  } = {}) {
684
684
  return new WorkspaceExecutor({ workspaceRoot, repoName });
685
685
  }
686
- /** The public env a native build was told to reproduce (`--public-env`): it outranks `.env`, which outranks the rest. */
686
+ /** The public env a native build was told to reproduce (`--public-env`): it outranks the shell, which outranks `.env`. */
687
687
  static publicEnvPin: Record<string, string> | null = null;
688
688
  static readonly localServeDomain = "localhost";
689
689
  static getBaseDevEnv(envPath?: string) {
690
690
  // Bun auto-loads .env, so we use process.env directly
691
691
  const sourceEnv = {
692
- ...(envPath ? { ...process.env, ...parseEnvFile(envPath) } : process.env),
692
+ ...(envPath ? parseEnvFile(envPath) : {}),
693
+ //? An empty export reads as unset, unlike in dotenv, so it can't blank a key the .env sets.
694
+ ...Object.fromEntries(Object.entries(process.env).filter(([, value]) => value)),
693
695
  ...WorkspaceExecutor.publicEnvPin,
694
696
  };
695
697
 
@@ -0,0 +1,121 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { FrameworkGraph } from "./frameworkGraph";
6
+
7
+ const workspaceRoot = path.resolve(import.meta.dir, "../../..");
8
+ //? The other end of these lies outside the source read here: @akanjs/native's desktop shell answers file and secret
9
+ //? requests, AkanApp's child reports a boot error from a code string, and the browser tab sends `sync-navigation` and
10
+ //? handles every HmrMessage in a script string. `ws.*` and HMR `ok` have no site.
11
+ const unrecognised = [
12
+ "AkanIpcMessage ws.opened sender",
13
+ "AkanIpcMessage ws.opened handler",
14
+ "AkanIpcMessage ws.closed sender",
15
+ "AkanIpcMessage ws.closed handler",
16
+ "AkanIpcMessage file.resolve handler",
17
+ "AkanIpcMessage file.resolved sender",
18
+ "AkanIpcMessage secret.request handler",
19
+ "AkanIpcMessage secret.answer sender",
20
+ "AkanIpcMessage error sender",
21
+ "HmrMessage sync-navigation sender",
22
+ "HmrMessage ok sender",
23
+ ];
24
+ const handledInBrowser = new Set(["HmrMessage"]);
25
+
26
+ const withWorkspace = async (files: Record<string, string>, run: (root: string) => Promise<void>) => {
27
+ const root = await mkdtemp(path.join(tmpdir(), "akan-framework-"));
28
+ try {
29
+ for (const [file, text] of Object.entries(files)) {
30
+ await mkdir(path.dirname(path.join(root, file)), { recursive: true });
31
+ await writeFile(path.join(root, file), text);
32
+ }
33
+ await run(root);
34
+ } finally {
35
+ await rm(root, { recursive: true, force: true });
36
+ }
37
+ };
38
+
39
+ const installed = (version: string) => ({
40
+ "node_modules/akanjs/package.json": JSON.stringify({
41
+ name: "akanjs",
42
+ version,
43
+ exports: { ".": "./index.ts", "./package.json": "./package.json" },
44
+ }),
45
+ "node_modules/akanjs/index.ts": `export * from "./base";\n`,
46
+ "node_modules/akanjs/base/scalar.ts": "export class Scalar {}\n",
47
+ "node_modules/akanjs/native/bridge.ts": `import { Native } from "../vendor/@akanjs/native/index";\nexport class Bridge {}\n`,
48
+ "node_modules/akanjs/vendor/@akanjs/native/index.ts": "export class Native {}\n",
49
+ "node_modules/akanjs/vendor/use-agentic/index.ts": "export class Agentic {}\n",
50
+ "node_modules/akanjs/types/base/index.ts": "export class Declared {}\n",
51
+ });
52
+
53
+ describe("FrameworkGraph", () => {
54
+ it(`keeps ${FrameworkGraph.architectureFile} current with the source it describes`, async () => {
55
+ const architecture = await FrameworkGraph.architecture(workspaceRoot);
56
+ expect(architecture).not.toBeNull();
57
+ if (!architecture) return;
58
+ const { map } = architecture;
59
+ expect(map.missing).toEqual([]);
60
+ expect(map.cycles).toEqual([]);
61
+ expect(architecture.unknownClasses).toEqual([]);
62
+ const unmatched = map.channels.flatMap(({ name, messages }) =>
63
+ messages.flatMap(({ type, senders, handlers }) => [
64
+ ...(senders.length ? [] : [`${name} ${type} sender`]),
65
+ ...(handlers.length || handledInBrowser.has(name) ? [] : [`${name} ${type} handler`]),
66
+ ]),
67
+ );
68
+ expect(unmatched).toEqual(unrecognised);
69
+ if (architecture.next !== architecture.current)
70
+ throw new Error(`${FrameworkGraph.architectureFile} is stale. Regenerate it: bun run akan sync-architecture`);
71
+ });
72
+
73
+ it("reads a consumer's installed akanjs without its bundled vendor and declaration folders", async () => {
74
+ await withWorkspace({ "package.json": "{}" }, async (root) => {
75
+ expect(await FrameworkGraph.build(root)).toBeNull();
76
+ });
77
+ await withWorkspace(installed("3.1.0"), async (root) => {
78
+ expect(await FrameworkGraph.packagesOf(root)).toEqual([
79
+ { name: "akanjs", root: "node_modules/akanjs", split: true },
80
+ ]);
81
+ const map = await FrameworkGraph.build(root);
82
+ expect(map?.facets.map(({ id, layer, classes }) => `${id}:${layer}:${classes.map(({ name }) => name)}`)).toEqual([
83
+ "akanjs/base:0:Scalar",
84
+ "akanjs/native:0:Bridge",
85
+ ]);
86
+ expect(await FrameworkGraph.architecture(root)).toBeNull();
87
+ });
88
+ });
89
+
90
+ it("rebuilds an installed map on a new version, and an in-repo one on a save under it", async () => {
91
+ await withWorkspace(installed("3.1.0"), async (root) => {
92
+ const first = await FrameworkGraph.cached(root);
93
+ FrameworkGraph.touched(root, [path.join(root, "node_modules/akanjs/base/scalar.ts")]);
94
+ expect(await FrameworkGraph.cached(root)).toBe(first);
95
+ await writeFile(path.join(root, "node_modules/akanjs/package.json"), JSON.stringify({ version: "3.1.1" }));
96
+ expect(await FrameworkGraph.cached(root)).not.toBe(first);
97
+ });
98
+ await withWorkspace({ "pkgs/akanjs/base/scalar.ts": "export class Scalar {}\n" }, async (root) => {
99
+ const first = await FrameworkGraph.cached(root);
100
+ FrameworkGraph.touched(root, [path.join(root, "apps/demo/page/_index.tsx")]);
101
+ expect(await FrameworkGraph.cached(root)).toBe(first);
102
+ FrameworkGraph.touched(root, [path.join(root, "pkgs/akanjs/base/scalar.ts")]);
103
+ expect(await FrameworkGraph.cached(root)).not.toBe(first);
104
+ });
105
+ });
106
+
107
+ it("reads a document checked out with CRLF line endings as current", async () => {
108
+ const files = {
109
+ "pkgs/akanjs/base/scalar.ts": "export class Scalar {}\n",
110
+ [FrameworkGraph.architectureFile]: "# A\n",
111
+ };
112
+ await withWorkspace(files, async (root) => {
113
+ const first = await FrameworkGraph.architecture(root);
114
+ if (!first) throw new Error("no architecture");
115
+ await writeFile(path.join(root, FrameworkGraph.architectureFile), first.next.replace(/\n/g, "\r\n"));
116
+ const second = await FrameworkGraph.architecture(root);
117
+ expect(second?.current).toBe(first.next);
118
+ expect(second?.next).toBe(first.next);
119
+ });
120
+ });
121
+ });