@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.
- package/CHANGELOG.md +348 -0
- package/abstractDoc.ts +1 -1
- package/agentAssets.ts +2 -2
- package/akanConfig/akanConfig.ts +2 -2
- package/akanMcpContract.ts +2 -2
- package/apiReference.test.ts +37 -0
- package/apiReference.ts +240 -0
- package/applicationBuildReporter.test.ts +54 -0
- package/applicationBuildReporter.ts +12 -0
- package/applicationBuildRunner.test.ts +34 -14
- package/applicationBuildRunner.ts +77 -14
- package/artifact/implicitRootLayout.test.ts +42 -0
- package/artifact/implicitRootLayout.ts +5 -6
- package/artifact/storeBootstrap.ts +24 -0
- package/cloud/cloudApi.test.ts +70 -7
- package/cloud/cloudApi.ts +10 -4
- package/cloud/constants.ts +3 -0
- package/cloud/globalConfig.test.ts +36 -0
- package/cloud/globalConfig.ts +10 -1
- package/codeAgent/skills/akan-debug/SKILL.md +2 -1
- package/codeAgent/skills/akan-page/SKILL.md +30 -2
- package/codeAgent/skills/akan-service/SKILL.md +77 -1
- package/commandDecorators/command.ts +55 -9
- package/commandDecorators/commandDecorators.test.ts +131 -2
- package/declarationReader.ts +211 -0
- package/executors.test.ts +65 -8
- package/executors.ts +5 -3
- package/frameworkGraph.test.ts +121 -0
- package/frameworkGraph.ts +165 -0
- package/frontendBuild/allRoutesBuilder.test.ts +84 -0
- package/frontendBuild/allRoutesBuilder.ts +32 -12
- package/frontendBuild/buildRouteClient.test.ts +192 -2
- package/frontendBuild/bundleDefine.ts +4 -0
- package/frontendBuild/clientBuildTypes.ts +7 -1
- package/frontendBuild/clientEntriesBundler.test.ts +82 -0
- package/frontendBuild/clientEntriesBundler.ts +55 -4
- package/frontendBuild/csrArtifactBuilder.test.ts +23 -0
- package/frontendBuild/csrArtifactBuilder.ts +2 -1
- package/frontendBuild/fontOptimizer.test.ts +54 -0
- package/frontendBuild/fontOptimizer.ts +5 -4
- package/frontendBuild/index.ts +3 -0
- package/frontendBuild/routeClientBuilder.ts +6 -2
- package/frontendBuild/routeModulePreloads.test.ts +59 -0
- package/frontendBuild/routeModulePreloads.ts +52 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +35 -10
- package/frontendBuild/ssrDevBundler.test.ts +26 -0
- package/frontendBuild/ssrDevBundler.ts +2 -1
- package/frontendBuild/vendorImportScanner.test.ts +71 -0
- package/frontendBuild/vendorImportScanner.ts +80 -0
- package/frontendBuild/vendorNarrowing.test.ts +103 -0
- package/frontendBuild/vendorNarrowing.ts +64 -0
- package/frontendBuild/vendorSpecifiers.ts +13 -0
- package/gitRevision.test.ts +82 -0
- package/gitRevision.ts +128 -0
- package/incrementalBuilder/builderGraphPayloads.ts +60 -0
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +105 -6
- package/incrementalBuilder/incrementalBuilder.proc.ts +6 -10
- package/integration/devStability.integration.test.ts +1 -1
- package/integration/devStabilityHarness.test.ts +1 -1
- package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +4 -2
- package/lint/__fixtures__/no-js-private-class-method/bad.tsx +2 -0
- package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -2
- package/lint/no-daisyui-legacy-class.grit +9 -7
- package/lint/no-deep-internal-import.grit +1 -1
- package/lint/no-js-private-class-method.grit +9 -28
- package/maintainabilityBaseline.ts +7 -2
- package/maintainabilityContract.test.ts +183 -0
- package/maintainabilityContract.ts +145 -30
- package/maintainabilityExceptionScanner.test.ts +13 -2
- package/maintainabilityExceptionScanner.ts +16 -6
- package/maintainabilityLimits.ts +12 -0
- package/maintainabilityReport.test.ts +35 -0
- package/maintainabilityReport.ts +29 -8
- package/mobile/nativePublicEnv.test.ts +5 -1
- package/package.json +3 -3
- package/routeJsBudget.test.ts +177 -0
- package/routeJsBudget.ts +222 -0
- package/structureScanner.test.ts +167 -0
- package/structureScanner.ts +164 -0
- package/transforms/barrelAnalyzer.ts +10 -3
- package/transforms/barrelImportsPlugin.ts +13 -7
- package/transforms/serverEnvPlugin.test.ts +1 -1
- package/transforms/serverEnvPlugin.ts +1 -1
- package/transforms/transforms.test.ts +64 -12
- package/transforms/tsconfigPackageResolver.test.ts +12 -0
- package/typeChecker.test.ts +100 -0
- package/typeChecker.ts +14 -2
- package/workflow/plan.ts +79 -17
- package/workflow/types.ts +1 -0
- package/workspaceGraph.test.ts +121 -0
- package/workspaceGraph.ts +149 -16
- package/workspaceLayout.ts +1 -0
- /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 {
|
|
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/
|
|
308
|
-
await readFile(path.join(templateRoot, "
|
|
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/
|
|
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/
|
|
322
|
+
template: "app/public/favicon.ico",
|
|
323
323
|
overwrite: false,
|
|
324
324
|
});
|
|
325
325
|
|
|
326
|
-
expect(await readFile(path.join(root, "app/public/
|
|
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/
|
|
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
|
|
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
|
|
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 ?
|
|
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
|
+
});
|