@akanjs/devkit 3.1.1-rc.9 → 3.1.2-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 +117 -0
- package/agentAssets.test.ts +65 -0
- package/agentAssets.ts +166 -0
- package/agentsIndex.ts +4 -4
- package/akanApp/akanApp.host.test.ts +12 -0
- package/akanApp/akanApp.host.ts +12 -0
- package/akanConfig/akanConfig.test.ts +105 -0
- package/akanConfig/akanConfig.ts +21 -1
- package/akanConfig/productionLockfile.test.ts +31 -0
- package/akanConfig/productionLockfile.ts +2 -0
- package/akanContext.ts +129 -4
- package/akanMcpContract.test.ts +28 -1
- package/akanMcpContract.ts +51 -3
- package/applicationBuildRunner.test.ts +53 -0
- package/applicationBuildRunner.ts +7 -4
- package/artifact/implicitRootLayout.ts +4 -2
- package/biome.base.json +4 -1
- package/cloud/globalConfig.test.ts +34 -0
- package/cloud/globalConfig.ts +2 -2
- package/codeAgent/agent/AkanCodeEnv.ts +26 -0
- package/codeAgent/agent/AkanCodeServices.ts +5 -2
- package/codeAgent/agent/CodeAgent.ts +11 -0
- package/codeAgent/agent/CodeAgentGate.ts +156 -10
- package/codeAgent/agent/CodeAgentProxy.ts +5 -4
- package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
- package/codeAgent/agent/CodeAgentRpcListener.ts +102 -16
- package/codeAgent/agent/SubagentPool.ts +5 -0
- package/codeAgent/agent/WorkspaceTrust.ts +200 -0
- package/codeAgent/agent/akanCodePaths.ts +8 -1
- package/codeAgent/agent/akanSystemPrompt.ts +5 -1
- package/codeAgent/agent/codeAgent.test.ts +41 -2
- package/codeAgent/agent/codeAgentContract.test.ts +119 -0
- package/codeAgent/agent/codeAgentHosts.ts +30 -0
- package/codeAgent/agent/codeAgentProxy.test.ts +53 -1
- package/codeAgent/agent/codeAgentRpcListener.test.ts +115 -13
- package/codeAgent/agent/stringArg.ts +1 -1
- package/codeAgent/feedback/PreviewFeedback.ts +2 -2
- package/codeAgent/feedback/VerifyFeedback.ts +3 -4
- package/codeAgent/index.ts +2 -2
- package/codeAgent/skills/akan-agent/SKILL.md +43 -0
- package/codeAgent/skills/akan-code/SKILL.md +38 -11
- package/codeAgent/skills/akan-debug/SKILL.md +41 -6
- package/codeAgent/skills/akan-deps/SKILL.md +1 -2
- package/codeAgent/skills/akan-err/SKILL.md +2 -1
- package/codeAgent/skills/akan-mcp/SKILL.md +40 -0
- package/codeAgent/skills/akan-page/SKILL.md +44 -0
- package/codeAgent/skills/akan-query/SKILL.md +39 -0
- package/codeAgent/skills/akan-runtime/SKILL.md +43 -0
- package/codeAgent/skills/akan-scaffold/SKILL.md +16 -6
- package/codeAgent/skills/akan-transport/SKILL.md +40 -0
- package/codeAgent/skills/akan-validate/SKILL.md +13 -11
- package/codeAgent/tools/AkanCli.ts +2 -1
- package/codeAgent/tools/AkanCodePlugins.ts +5 -1
- package/codeAgent/tools/AkanEditScope.ts +8 -3
- package/codeAgent/tools/AkanToolPack.ts +4 -10
- package/codeAgent/tools/AkanVerifier.ts +13 -31
- package/codeAgent/tools/McpServerConfig.ts +6 -4
- package/codeAgent/tools/McpSignIn.ts +7 -7
- package/codeAgent/tools/McpTokenStore.ts +63 -16
- package/codeAgent/tools/McpToolPack.ts +8 -1
- package/codeAgent/tools/WebToolPack.ts +50 -6
- package/codeAgent/tools/akanCodePlugins.test.ts +130 -60
- package/codeAgent/tools/mcpOAuth.test.ts +50 -14
- package/codeAgent/tools/mcpServerConfig.test.ts +130 -1
- package/commandDecorators/command.ts +4 -1
- package/commandDecorators/workspaceVersionCheck.test.ts +17 -7
- package/commandDecorators/workspaceVersionCheck.ts +4 -1
- package/commentDensityScanner.test.ts +121 -0
- package/commentDensityScanner.ts +101 -0
- package/componentSizeScanner.test.ts +36 -0
- package/componentSizeScanner.ts +52 -0
- package/componentWriteChainScanner.test.ts +137 -0
- package/componentWriteChainScanner.ts +104 -0
- package/conventionSet.test.ts +73 -0
- package/conventionSet.ts +67 -0
- package/createTunnel.test.ts +200 -2
- package/createTunnel.ts +80 -6
- package/csrE2e/ssrRegistry.e2e.test.ts +6 -1
- package/deprecatedCommentScanner.test.ts +54 -0
- package/deprecatedCommentScanner.ts +30 -0
- package/endpointReach.test.ts +58 -0
- package/endpointReach.ts +80 -0
- package/envSeed.test.ts +90 -0
- package/envSeed.ts +68 -0
- package/executors.test.ts +19 -5
- package/executors.ts +59 -4
- package/fileSys.test.ts +30 -2
- package/fileSys.ts +14 -0
- package/formSetterScanner.test.ts +44 -1
- package/formSetterScanner.ts +53 -8
- package/frontendBuild/csrArtifactBuilder.ts +7 -0
- package/frontendBuild/mcpAppViewBuilder.test.ts +63 -0
- package/frontendBuild/mcpAppViewBuilder.ts +7 -1
- package/frontendBuild/optionalPeers.test.ts +132 -0
- package/index.ts +1 -0
- package/integration/devStability.integration.test.ts +9 -5
- package/integration/ssrMemoryProbe.ts +6 -0
- package/kindBranchScanner.test.ts +89 -0
- package/kindBranchScanner.ts +158 -0
- package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
- package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
- package/lint/no-oversized-list-limit.grit +73 -0
- package/lint/no-unguarded-endpoint.grit +5 -5
- package/linter.test.ts +12 -0
- package/linter.ts +35 -12
- package/maintainabilityBaseline.test.ts +280 -0
- package/maintainabilityBaseline.ts +220 -0
- package/maintainabilityContract.test.ts +846 -0
- package/maintainabilityContract.ts +404 -0
- package/maintainabilityExceptionScanner.test.ts +90 -0
- package/maintainabilityExceptionScanner.ts +85 -0
- package/maintainabilityLimits.ts +60 -0
- package/maintainabilityReport.test.ts +130 -0
- package/maintainabilityReport.ts +153 -0
- package/mobile/desktopRelease.test.ts +1 -1
- package/mobile/desktopRelease.ts +3 -1
- package/mobile/desktopServerStage.test.ts +1 -0
- package/mobile/desktopServerStage.ts +3 -1
- package/mobile/nativeApp.test.ts +20 -5
- package/mobile/nativeApp.ts +4 -1
- package/mobile/nativeConfig.test.ts +209 -5
- package/mobile/nativeConfig.ts +127 -6
- package/mobile/nativePublicEnv.test.ts +2 -0
- package/mobile/nativePublicEnv.ts +13 -0
- package/package.json +4 -2
- package/platformTest/LinuxDockerTarget.ts +3 -0
- package/{codeAgent/feedback → preview}/PreviewChrome.ts +13 -7
- package/preview/PreviewView.ts +219 -0
- package/preview/index.ts +2 -0
- package/preview/previewView.test.ts +76 -0
- package/prompter.ts +7 -0
- package/qualityScanner.test.ts +75 -0
- package/qualityScanner.ts +17 -1
- package/routeSourceValidator.test.ts +11 -2
- package/routeSourceValidator.ts +14 -2
- package/serviceLoopCallScanner.test.ts +56 -0
- package/serviceLoopCallScanner.ts +122 -0
- package/shellOnlyEnv.test.ts +65 -0
- package/shellOnlyEnv.ts +54 -0
- package/signalMutationIndex.test.ts +15 -0
- package/signalMutationIndex.ts +23 -82
- package/sourceComments.test.ts +45 -0
- package/sourceComments.ts +70 -0
- package/sshHostKeys.ts +59 -0
- package/storeAccessScanner.test.ts +84 -0
- package/storeAccessScanner.ts +71 -0
- package/storeSource.ts +160 -0
- package/storeWriteScanner.test.ts +292 -0
- package/storeWriteScanner.ts +50 -0
- package/subspace.test.ts +10 -0
- package/subspaceClone.ts +3 -1
- package/subspaceConfig.ts +6 -0
- package/testOnlyExportScanner.test.ts +83 -0
- package/testOnlyExportScanner.ts +124 -0
- package/transforms/barrelImportsPlugin.ts +10 -4
- package/transforms/mcpAppPageTrimmer.test.ts +8 -2
- package/transforms/mcpAppPageTrimmer.ts +5 -20
- package/transforms/pureLazyMarker.ts +1 -1
- package/typeChecker.ts +32 -0
- package/typecheck/projectTypecheck.test.ts +18 -0
- package/typecheck/projectTypecheck.ts +29 -5
- package/typecheck/typecheck.proc.ts +6 -0
- package/typecheck/typecheckFailure.ts +13 -0
- package/types.ts +1 -0
- package/workflow/artifacts.ts +16 -2
- package/workflow/executor.ts +22 -7
- package/workflow/guardCatalog.test.ts +59 -0
- package/workflow/guardCatalog.ts +108 -0
- package/workflow/index.ts +4 -0
- package/workflow/interactionScaffold.ts +122 -0
- package/workflow/mutationScaffold.ts +533 -0
- package/workflow/mutationSource.test.ts +108 -0
- package/workflow/mutationSource.ts +219 -0
- package/workflow/plan.ts +82 -0
- package/workflow/render.ts +19 -4
- package/workflow/source.test.ts +185 -0
- package/workflow/source.ts +151 -24
- package/workflow/sourceAst.ts +10 -48
- package/workflow/types.ts +18 -1
- package/workspaceLayout.ts +2 -0
- package/writePath.test.ts +109 -0
- package/writePath.ts +219 -0
- package/writePlacementScanner.test.ts +77 -0
- package/writePlacementScanner.ts +50 -0
- package/writingActionIndex.test.ts +99 -0
- package/writingActionIndex.ts +63 -0
- package/codeAgent/feedback/PreviewView.ts +0 -95
- /package/{codeAgent/feedback → preview}/previewChrome.test.ts +0 -0
package/conventionSet.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fsPromise from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface ConventionRule {
|
|
5
|
+
name: string;
|
|
6
|
+
description: string;
|
|
7
|
+
paths: string[];
|
|
8
|
+
body: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class ConventionSet {
|
|
12
|
+
constructor(
|
|
13
|
+
readonly core: string,
|
|
14
|
+
readonly rules: ConventionRule[],
|
|
15
|
+
) {}
|
|
16
|
+
|
|
17
|
+
static async load(dir: string) {
|
|
18
|
+
const core = await fsPromise.readFile(path.join(dir, "core.md"), "utf-8");
|
|
19
|
+
const files = (await fsPromise.readdir(path.join(dir, "rules"))).filter((file) => file.endsWith(".md")).sort();
|
|
20
|
+
const rules = await Promise.all(
|
|
21
|
+
files.map(async (file) =>
|
|
22
|
+
ConventionSet.parseRule(file.slice(0, -3), await fsPromise.readFile(path.join(dir, "rules", file), "utf-8")),
|
|
23
|
+
),
|
|
24
|
+
);
|
|
25
|
+
return new ConventionSet(core.trim(), rules);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static parseRule(name: string, text: string): ConventionRule {
|
|
29
|
+
const match: RegExpExecArray | null = /^---\n([\s\S]*?)\n---\n/.exec(text);
|
|
30
|
+
if (!match) throw new Error(`Convention rule ${name} has no frontmatter`);
|
|
31
|
+
const front = match[1] ?? "";
|
|
32
|
+
const described: RegExpExecArray | null = /^description:\s*(.+)$/m.exec(front);
|
|
33
|
+
const description = described?.[1]?.trim() ?? "";
|
|
34
|
+
const paths = [...front.matchAll(/^\s+-\s+"([^"]+)"$/gm)].map((entry) => entry[1] ?? "");
|
|
35
|
+
if (!description || paths.length === 0) throw new Error(`Convention rule ${name} needs a description and paths`);
|
|
36
|
+
return { name, description, paths, body: text.slice(match[0].length).trim() };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
//? Cursor splits `globs:` on commas, so a `{a,b}` brace list has to be expanded before it is written there.
|
|
40
|
+
static expand(glob: string): string[] {
|
|
41
|
+
const open = glob.indexOf("{");
|
|
42
|
+
if (open === -1) return [glob];
|
|
43
|
+
const close = glob.indexOf("}", open);
|
|
44
|
+
if (close === -1) return [glob];
|
|
45
|
+
const head = glob.slice(0, open);
|
|
46
|
+
const tail = glob.slice(close + 1);
|
|
47
|
+
return glob
|
|
48
|
+
.slice(open + 1, close)
|
|
49
|
+
.split(",")
|
|
50
|
+
.flatMap((choice) => ConventionSet.expand(`${head}${choice}${tail}`));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
static demote(markdown: string) {
|
|
54
|
+
let fenced = false;
|
|
55
|
+
return markdown
|
|
56
|
+
.split("\n")
|
|
57
|
+
.map((line) => {
|
|
58
|
+
if (line.startsWith("```")) fenced = !fenced;
|
|
59
|
+
return !fenced && /^#{1,5} /.test(line) ? `#${line}` : line;
|
|
60
|
+
})
|
|
61
|
+
.join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get instruction() {
|
|
65
|
+
return `${[this.core, ...this.rules.map((rule) => ConventionSet.demote(rule.body))].join("\n\n")}\n`;
|
|
66
|
+
}
|
|
67
|
+
}
|
package/createTunnel.test.ts
CHANGED
|
@@ -1,8 +1,76 @@
|
|
|
1
|
-
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
|
-
import {
|
|
1
|
+
import { afterEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import type { AddressInfo } from "node:net";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { Client, type Connection, Server, utils } from "ssh2";
|
|
3
6
|
import { createTunnel } from "./createTunnel";
|
|
7
|
+
import { SshHostKeys } from "./sshHostKeys";
|
|
4
8
|
import { createTempApp, isolateEnv, tempRoots } from "./testHelpers";
|
|
5
9
|
|
|
10
|
+
const freePort = () => {
|
|
11
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
12
|
+
const { port } = listener;
|
|
13
|
+
listener.stop(true);
|
|
14
|
+
return port;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const publicBlobOf = (publicKey: string) => {
|
|
18
|
+
const parsed = utils.parseKey(publicKey);
|
|
19
|
+
if (parsed instanceof Error) throw parsed;
|
|
20
|
+
return parsed.getPublicSSH();
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const sshServers: { server: Server; clients: Set<Connection> }[] = [];
|
|
24
|
+
|
|
25
|
+
const startSshServer = async () => {
|
|
26
|
+
const hostKey = utils.generateKeyPairSync("ed25519");
|
|
27
|
+
const methods: string[] = [];
|
|
28
|
+
const clients = new Set<Connection>();
|
|
29
|
+
const server = new Server({ hostKeys: [hostKey.private] }, (client) => {
|
|
30
|
+
clients.add(client);
|
|
31
|
+
client.on("error", () => clients.delete(client));
|
|
32
|
+
client.on("authentication", (context) => {
|
|
33
|
+
methods.push(context.method);
|
|
34
|
+
if (context.method === "password" || context.method === "publickey") context.accept();
|
|
35
|
+
else context.reject(["publickey", "password"]);
|
|
36
|
+
});
|
|
37
|
+
client.on("ready", () => {
|
|
38
|
+
client.on("tcpip", (accept) => {
|
|
39
|
+
const channel = accept();
|
|
40
|
+
channel.on("data", (chunk: Buffer) => channel.write(chunk));
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
|
|
45
|
+
sshServers.push({ server, clients });
|
|
46
|
+
const { port } = server.address() as AddressInfo;
|
|
47
|
+
return { port, methods, fingerprint: SshHostKeys.fingerprintOf(publicBlobOf(hostKey.public)) };
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const echoThrough = async (port: number) =>
|
|
51
|
+
await new Promise<string>((resolve, reject) => {
|
|
52
|
+
void Bun.connect({
|
|
53
|
+
hostname: "127.0.0.1",
|
|
54
|
+
port,
|
|
55
|
+
socket: {
|
|
56
|
+
open: (socket) => void socket.write("ping"),
|
|
57
|
+
data: (socket, chunk) => {
|
|
58
|
+
resolve(Buffer.from(chunk).toString());
|
|
59
|
+
socket.end();
|
|
60
|
+
},
|
|
61
|
+
error: (_socket, error) => reject(error),
|
|
62
|
+
connectError: (_socket, error) => reject(error),
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
for (const { server, clients } of sshServers.splice(0)) {
|
|
69
|
+
for (const client of clients) client.end();
|
|
70
|
+
server.close();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
6
74
|
describe("createTunnel", () => {
|
|
7
75
|
isolateEnv({ AKAN_PUBLIC_REPO_NAME: "repo", AKAN_PUBLIC_SERVE_DOMAIN: "localhost", AKAN_PUBLIC_ENV: "testing" });
|
|
8
76
|
const track = tempRoots();
|
|
@@ -62,4 +130,134 @@ describe("createTunnel", () => {
|
|
|
62
130
|
connect.mockRestore();
|
|
63
131
|
}
|
|
64
132
|
});
|
|
133
|
+
|
|
134
|
+
test("forwards on loopback, signs in with the configured key and keeps the host key it first sees", async () => {
|
|
135
|
+
const { root, app } = track(await createTempApp("demo"));
|
|
136
|
+
const ssh = await startSshServer();
|
|
137
|
+
const knownHosts = path.join(root, "known_hosts.json");
|
|
138
|
+
const keyPath = path.join(root, "id_tunnel");
|
|
139
|
+
await writeFile(keyPath, utils.generateKeyPairSync("ed25519").private);
|
|
140
|
+
Object.assign(process.env, {
|
|
141
|
+
SSH_TUNNEL_PORT: String(ssh.port),
|
|
142
|
+
SSH_TUNNEL_KNOWN_HOSTS: knownHosts,
|
|
143
|
+
SSH_TUNNEL_PRIVATE_KEY_PATH: keyPath,
|
|
144
|
+
});
|
|
145
|
+
const localPort = freePort();
|
|
146
|
+
const listen = spyOn(Bun, "listen");
|
|
147
|
+
try {
|
|
148
|
+
expect(await createTunnel("redis", { app, environment: "testing", port: localPort })).toBe(
|
|
149
|
+
`localhost:${localPort}`,
|
|
150
|
+
);
|
|
151
|
+
expect(listen.mock.calls[0]?.[0]).toMatchObject({ hostname: "127.0.0.1", port: localPort });
|
|
152
|
+
} finally {
|
|
153
|
+
listen.mockRestore();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
expect(await echoThrough(localPort)).toBe("ping");
|
|
157
|
+
expect(ssh.methods).toContain("publickey");
|
|
158
|
+
expect(ssh.methods).not.toContain("password");
|
|
159
|
+
expect(JSON.parse(await readFile(knownHosts, "utf8"))).toEqual({ [`localhost:${ssh.port}`]: ssh.fingerprint });
|
|
160
|
+
if (process.platform !== "win32") expect((await stat(knownHosts)).mode & 0o777).toBe(0o600);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("refuses a host whose key differs from the kept or the pinned one", async () => {
|
|
164
|
+
const { root, app } = track(await createTempApp("demo"));
|
|
165
|
+
const ssh = await startSshServer();
|
|
166
|
+
const knownHosts = path.join(root, "known_hosts.json");
|
|
167
|
+
const other = `SHA256:${Buffer.alloc(32, 1).toString("base64").replace(/=+$/, "")}`;
|
|
168
|
+
Object.assign(process.env, {
|
|
169
|
+
SSH_TUNNEL_PORT: String(ssh.port),
|
|
170
|
+
SSH_TUNNEL_KNOWN_HOSTS: knownHosts,
|
|
171
|
+
SSH_TUNNEL_PASSWORD: "test-password",
|
|
172
|
+
});
|
|
173
|
+
const open = async () => await createTunnel("redis", { app, environment: "testing", port: freePort() });
|
|
174
|
+
|
|
175
|
+
await writeFile(knownHosts, JSON.stringify({ [`localhost:${ssh.port}`]: other }));
|
|
176
|
+
await expect(open()).rejects.toThrow(`differs from the one ${knownHosts} keeps`);
|
|
177
|
+
expect(JSON.parse(await readFile(knownHosts, "utf8"))).toEqual({ [`localhost:${ssh.port}`]: other });
|
|
178
|
+
|
|
179
|
+
process.env.SSH_TUNNEL_HOST_FINGERPRINT = other;
|
|
180
|
+
await expect(open()).rejects.toThrow("is not one SSH_TUNNEL_HOST_FINGERPRINT pins");
|
|
181
|
+
process.env.SSH_TUNNEL_HOST_FINGERPRINT = ssh.fingerprint;
|
|
182
|
+
await expect(open()).resolves.toStartWith("localhost:");
|
|
183
|
+
expect(ssh.methods.filter((method) => method !== "none")).toEqual(["password"]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("warns when it signs in with the default shared credential", async () => {
|
|
187
|
+
const { root, app } = track(await createTempApp("demo"));
|
|
188
|
+
const ssh = await startSshServer();
|
|
189
|
+
Object.assign(process.env, {
|
|
190
|
+
SSH_TUNNEL_PORT: String(ssh.port),
|
|
191
|
+
SSH_TUNNEL_KNOWN_HOSTS: path.join(root, "known_hosts.json"),
|
|
192
|
+
});
|
|
193
|
+
//? `akan test` runs from the workspace root, whose dotenv may name a password; this case is the one with none.
|
|
194
|
+
delete process.env.SSH_TUNNEL_PASSWORD;
|
|
195
|
+
delete process.env.SSH_TUNNEL_PRIVATE_KEY_PATH;
|
|
196
|
+
const warn = spyOn(app.logger, "warn");
|
|
197
|
+
|
|
198
|
+
await createTunnel("redis", { app, environment: "testing", port: freePort() });
|
|
199
|
+
|
|
200
|
+
expect(warn.mock.calls.map(([message]) => String(message)).join("\n")).toContain(
|
|
201
|
+
"uses the default shared credential",
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("listens where SSH_TUNNEL_BIND_HOST says and warns when that is not loopback", async () => {
|
|
206
|
+
const { root, app } = track(await createTempApp("demo"));
|
|
207
|
+
const ssh = await startSshServer();
|
|
208
|
+
Object.assign(process.env, {
|
|
209
|
+
SSH_TUNNEL_PORT: String(ssh.port),
|
|
210
|
+
SSH_TUNNEL_KNOWN_HOSTS: path.join(root, "known_hosts.json"),
|
|
211
|
+
SSH_TUNNEL_PASSWORD: "test-password",
|
|
212
|
+
SSH_TUNNEL_BIND_HOST: "0.0.0.0",
|
|
213
|
+
});
|
|
214
|
+
const warn = spyOn(app.logger, "warn");
|
|
215
|
+
const localPort = freePort();
|
|
216
|
+
const listen = spyOn(Bun, "listen");
|
|
217
|
+
try {
|
|
218
|
+
await createTunnel("redis", { app, environment: "testing", port: localPort });
|
|
219
|
+
expect(listen.mock.calls[0]?.[0]).toMatchObject({ hostname: "0.0.0.0", port: localPort });
|
|
220
|
+
} finally {
|
|
221
|
+
listen.mockRestore();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
expect(warn.mock.calls.map(([message]) => String(message)).join("\n")).toContain(
|
|
225
|
+
"The redis tunnel listens on 0.0.0.0 (SSH_TUNNEL_BIND_HOST)",
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("takes no host check or listener the repo's dotenv file names, and says it ignored them", async () => {
|
|
230
|
+
const { root, app } = track(await createTempApp("demo"));
|
|
231
|
+
const ssh = await startSshServer();
|
|
232
|
+
const knownHosts = path.join(root, "known_hosts.json");
|
|
233
|
+
const other = `SHA256:${Buffer.alloc(32, 1).toString("base64").replace(/=+$/, "")}`;
|
|
234
|
+
await writeFile(path.join(root, ".env"), "SSH_TUNNEL_HOST_FINGERPRINT=*\nexport SSH_TUNNEL_BIND_HOST=0.0.0.0\n");
|
|
235
|
+
Object.assign(process.env, {
|
|
236
|
+
SSH_TUNNEL_PORT: String(ssh.port),
|
|
237
|
+
SSH_TUNNEL_KNOWN_HOSTS: knownHosts,
|
|
238
|
+
SSH_TUNNEL_PASSWORD: "test-password",
|
|
239
|
+
SSH_TUNNEL_HOST_FINGERPRINT: "*",
|
|
240
|
+
SSH_TUNNEL_BIND_HOST: "0.0.0.0",
|
|
241
|
+
});
|
|
242
|
+
const warn = spyOn(app.logger, "warn");
|
|
243
|
+
const listen = spyOn(Bun, "listen");
|
|
244
|
+
const cwd = process.cwd();
|
|
245
|
+
process.chdir(root);
|
|
246
|
+
try {
|
|
247
|
+
const open = async () => await createTunnel("redis", { app, environment: "testing", port: freePort() });
|
|
248
|
+
await writeFile(knownHosts, JSON.stringify({ [`localhost:${ssh.port}`]: other }));
|
|
249
|
+
await expect(open()).rejects.toThrow(`differs from the one ${knownHosts} keeps`);
|
|
250
|
+
await writeFile(knownHosts, JSON.stringify({ [`localhost:${ssh.port}`]: ssh.fingerprint }));
|
|
251
|
+
await expect(open()).resolves.toStartWith("localhost:");
|
|
252
|
+
expect(listen.mock.calls.at(-1)?.[0]).toMatchObject({ hostname: "127.0.0.1" });
|
|
253
|
+
} finally {
|
|
254
|
+
process.chdir(cwd);
|
|
255
|
+
listen.mockRestore();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const warnings = warn.mock.calls.map(([message]) => String(message)).join("\n");
|
|
259
|
+
expect(warnings).toContain("SSH_TUNNEL_HOST_FINGERPRINT is set in .env, which the repository controls");
|
|
260
|
+
expect(warnings).toContain("SSH_TUNNEL_BIND_HOST is set in .env");
|
|
261
|
+
expect(warnings).not.toContain("accepts any host key");
|
|
262
|
+
});
|
|
65
263
|
});
|
package/createTunnel.ts
CHANGED
|
@@ -1,36 +1,110 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import type { ConnectConfig } from "ssh2";
|
|
1
3
|
import { type AppExecutor, WorkspaceExecutor } from "./executors";
|
|
4
|
+
import { ShellOnlyEnv } from "./shellOnlyEnv";
|
|
5
|
+
import { SshHostKeys } from "./sshHostKeys";
|
|
2
6
|
import { createSshTunnel } from "./sshTunnel";
|
|
3
7
|
|
|
8
|
+
//? Loosening the host check or the listener, or picking a key, is the person's call; a repo's dotenv cannot set it.
|
|
9
|
+
const tunnelEnv = new ShellOnlyEnv([
|
|
10
|
+
"SSH_TUNNEL_PRIVATE_KEY_PATH",
|
|
11
|
+
"SSH_TUNNEL_PASSPHRASE",
|
|
12
|
+
"SSH_TUNNEL_HOST_FINGERPRINT",
|
|
13
|
+
"SSH_TUNNEL_KNOWN_HOSTS",
|
|
14
|
+
"SSH_TUNNEL_BIND_HOST",
|
|
15
|
+
]);
|
|
16
|
+
|
|
4
17
|
interface TunnelOption {
|
|
5
18
|
app: AppExecutor;
|
|
6
19
|
environment: string;
|
|
7
20
|
port?: number;
|
|
8
21
|
}
|
|
22
|
+
|
|
23
|
+
const tunnelAuth = (
|
|
24
|
+
app: AppExecutor,
|
|
25
|
+
repoName: string,
|
|
26
|
+
): Pick<ConnectConfig, "privateKey" | "passphrase" | "password"> => {
|
|
27
|
+
const keyPath = tunnelEnv.get("SSH_TUNNEL_PRIVATE_KEY_PATH");
|
|
28
|
+
const passphrase = tunnelEnv.get("SSH_TUNNEL_PASSPHRASE");
|
|
29
|
+
if (keyPath) return { privateKey: readFileSync(keyPath), ...(passphrase ? { passphrase } : {}) };
|
|
30
|
+
if (process.env.SSH_TUNNEL_PASSWORD) return { password: process.env.SSH_TUNNEL_PASSWORD };
|
|
31
|
+
app.logger.warn(
|
|
32
|
+
"The SSH tunnel uses the default shared credential. " +
|
|
33
|
+
"Set SSH_TUNNEL_PRIVATE_KEY_PATH to a key (or SSH_TUNNEL_PASSWORD) once the cluster's tunnel endpoint accepts it.",
|
|
34
|
+
);
|
|
35
|
+
return { password: repoName };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const tunnelHostVerifier = (app: AppExecutor, hostId: string, onRefusal: (reason: string) => void) => {
|
|
39
|
+
const pinned = SshHostKeys.parsePinned(tunnelEnv.get("SSH_TUNNEL_HOST_FINGERPRINT"));
|
|
40
|
+
const hostKeys = new SshHostKeys(tunnelEnv.get("SSH_TUNNEL_KNOWN_HOSTS") || undefined);
|
|
41
|
+
return (key: Buffer, verify: (valid: boolean) => void) => {
|
|
42
|
+
if (pinned.includes("SHA256:*")) {
|
|
43
|
+
app.logger.warn(`SSH_TUNNEL_HOST_FINGERPRINT=* accepts any host key ${hostId} shows.`);
|
|
44
|
+
verify(true);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
hostKeys.verify(hostId, key, pinned).then(
|
|
48
|
+
({ trusted, firstUse, fingerprint }) => {
|
|
49
|
+
if (firstUse)
|
|
50
|
+
app.logger.warn(
|
|
51
|
+
`Trusting the SSH host key ${fingerprint} of ${hostId} on first use; kept in ${hostKeys.filePath}.`,
|
|
52
|
+
);
|
|
53
|
+
if (!trusted)
|
|
54
|
+
onRefusal(
|
|
55
|
+
pinned.length
|
|
56
|
+
? `the host key ${fingerprint} of ${hostId} is not one SSH_TUNNEL_HOST_FINGERPRINT pins`
|
|
57
|
+
: `the host key ${fingerprint} of ${hostId} differs from the one ${hostKeys.filePath} keeps; if the host was rebuilt, remove its entry or pin the new key with SSH_TUNNEL_HOST_FINGERPRINT`,
|
|
58
|
+
);
|
|
59
|
+
verify(trusted);
|
|
60
|
+
},
|
|
61
|
+
(error: unknown) => {
|
|
62
|
+
onRefusal(
|
|
63
|
+
`the host key store ${hostKeys.filePath} could not be read (${error instanceof Error ? error.message : String(error)})`,
|
|
64
|
+
);
|
|
65
|
+
verify(false);
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
9
71
|
export const createTunnel = async (
|
|
10
72
|
service: "redis" | "postgres",
|
|
11
73
|
{ app, environment, port = service === "postgres" ? 5432 : 6379 }: TunnelOption,
|
|
12
74
|
) => {
|
|
13
75
|
const baseDevEnv = WorkspaceExecutor.getBaseDevEnv();
|
|
14
76
|
WorkspaceExecutor.assertDeployableEnv(baseDevEnv, environment, `The ${service} tunnel`);
|
|
77
|
+
for (const line of tunnelEnv.ignored()) app.logger.warn(line);
|
|
15
78
|
const { serveDomain, repoName } = baseDevEnv;
|
|
16
79
|
const host = `${app.name}-${environment}.${serveDomain}`;
|
|
80
|
+
//? RFC 6761 makes *.localhost loopback, but only macOS resolves it; glibc without systemd-resolved and Windows answer ENOTFOUND
|
|
81
|
+
const sshHost = host.endsWith(".localhost") ? "localhost" : host;
|
|
17
82
|
const sshPort = process.env.SSH_TUNNEL_PORT ? parseInt(process.env.SSH_TUNNEL_PORT) : 32767;
|
|
18
83
|
const dstHost = `${service}-0.${service}-svc.${app.name}-${environment}.svc.cluster.local`;
|
|
19
84
|
const dstPort = service === "postgres" ? 5432 : 6379;
|
|
85
|
+
//? The forward reaches a cluster database, so it listens on loopback unless SSH_TUNNEL_BIND_HOST widens it.
|
|
86
|
+
const bindHost = tunnelEnv.get("SSH_TUNNEL_BIND_HOST") || "127.0.0.1";
|
|
87
|
+
if (!["127.0.0.1", "localhost", "::1"].includes(bindHost))
|
|
88
|
+
app.logger.warn(
|
|
89
|
+
`The ${service} tunnel listens on ${bindHost} (SSH_TUNNEL_BIND_HOST), so other machines that reach it reach ${dstHost}.`,
|
|
90
|
+
);
|
|
91
|
+
let refusal: string | null = null;
|
|
20
92
|
try {
|
|
21
93
|
await createSshTunnel({
|
|
22
|
-
localHost:
|
|
94
|
+
localHost: bindHost,
|
|
23
95
|
localPort: port,
|
|
24
|
-
srcHost: "
|
|
96
|
+
srcHost: "127.0.0.1",
|
|
25
97
|
srcPort: port,
|
|
26
98
|
dstHost,
|
|
27
99
|
dstPort,
|
|
28
100
|
sshOptions: {
|
|
29
|
-
|
|
30
|
-
host: host.endsWith(".localhost") ? "localhost" : host,
|
|
101
|
+
host: sshHost,
|
|
31
102
|
port: sshPort,
|
|
32
103
|
username: process.env.SSH_TUNNEL_USERNAME ?? "root",
|
|
33
|
-
|
|
104
|
+
...tunnelAuth(app, repoName),
|
|
105
|
+
hostVerifier: tunnelHostVerifier(app, `${sshHost}:${sshPort}`, (reason) => {
|
|
106
|
+
refusal = reason;
|
|
107
|
+
}),
|
|
34
108
|
},
|
|
35
109
|
});
|
|
36
110
|
} catch (error) {
|
|
@@ -41,7 +115,7 @@ export const createTunnel = async (
|
|
|
41
115
|
? error.message
|
|
42
116
|
: String(error);
|
|
43
117
|
throw new Error(
|
|
44
|
-
`Could not open the ${service} tunnel for ${app.name}-${environment}: SSH ${host}:${sshPort} -> ${dstHost}:${dstPort} failed (${reasons})`,
|
|
118
|
+
`Could not open the ${service} tunnel for ${app.name}-${environment}: SSH ${host}:${sshPort} -> ${dstHost}:${dstPort} failed (${refusal ?? reasons})`,
|
|
45
119
|
{ cause: error },
|
|
46
120
|
);
|
|
47
121
|
}
|
|
@@ -61,7 +61,12 @@ describe.skipIf(!CsrE2eHarness.enabled)("SSR dev registry (minimal)", () => {
|
|
|
61
61
|
let ssr: CsrE2eHarness;
|
|
62
62
|
|
|
63
63
|
beforeAll(async () => {
|
|
64
|
-
ssr = await CsrE2eHarness.start({
|
|
64
|
+
ssr = await CsrE2eHarness.start({
|
|
65
|
+
app: "minimal",
|
|
66
|
+
port,
|
|
67
|
+
// The server-log assertions match verbose-level lines, which a workspace `.env` may or may not turn on.
|
|
68
|
+
env: { AKAN_DEV_SSR_ARM_DELAY_MS: "3000", AKAN_PUBLIC_LOG_LEVEL: "verbose" },
|
|
69
|
+
});
|
|
65
70
|
}, 240_000);
|
|
66
71
|
|
|
67
72
|
afterAll(async () => {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { DeprecatedCommentScanner } from "./deprecatedCommentScanner";
|
|
4
|
+
import type { SourceFileInfo } from "./qualityScanner";
|
|
5
|
+
|
|
6
|
+
const fileOf = (file: string, content: string): SourceFileInfo => ({
|
|
7
|
+
file,
|
|
8
|
+
absolutePath: `/repo/${file}`,
|
|
9
|
+
content,
|
|
10
|
+
sourceFile: ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const scan = (file: string, lines: string[]) =>
|
|
14
|
+
new DeprecatedCommentScanner().scan([fileOf(file, `${lines.join("\n")}\n`)]);
|
|
15
|
+
|
|
16
|
+
describe("DeprecatedCommentScanner", () => {
|
|
17
|
+
test("flags a trailing, a leading and a block deprecated comment", () => {
|
|
18
|
+
const warnings = scan("apps/demo/lib/court/court.constant.ts", [
|
|
19
|
+
"export class CourtInput extends via((field) => ({",
|
|
20
|
+
" legacyVenue: field(String).optional(), // deprecated",
|
|
21
|
+
" // Deprecated: use courtId",
|
|
22
|
+
" legacyCourt: field(String).optional(),",
|
|
23
|
+
" /* DEPRECATED */ oldName: field(String).optional(),",
|
|
24
|
+
" /** deprecated, read venueId */ oldVenue: field(String).optional(),",
|
|
25
|
+
"})) {}",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
expect(warnings.map((warning) => warning.line)).toEqual([2, 3, 5, 6]);
|
|
29
|
+
expect(warnings[0]).toMatchObject({ rule: "akan.comment.deprecated-marker", scope: "comment" });
|
|
30
|
+
expect(warnings[0]?.message).toContain('"// deprecated"');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("leaves a JSDoc @deprecated tag and prose that only mentions deprecation", () => {
|
|
34
|
+
expect(
|
|
35
|
+
scan("libs/demo/ui/field.type.ts", [
|
|
36
|
+
"export interface FieldProps {",
|
|
37
|
+
" /** @deprecated Ignored: the field renders `value`. */",
|
|
38
|
+
" defaultValue?: string;",
|
|
39
|
+
" /**",
|
|
40
|
+
" * Old name.",
|
|
41
|
+
" * @deprecated use `value`",
|
|
42
|
+
" */",
|
|
43
|
+
" initial?: string;",
|
|
44
|
+
"}",
|
|
45
|
+
"// Replaces the deprecated upstream plugin.",
|
|
46
|
+
"export const notice = '// deprecated';",
|
|
47
|
+
]),
|
|
48
|
+
).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("stays out of tests", () => {
|
|
52
|
+
expect(scan("apps/demo/lib/court/court.signal.test.ts", ["const venue = 1; // deprecated"])).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
|
|
2
|
+
import { type SourceComment, SourceComments } from "./sourceComments";
|
|
3
|
+
|
|
4
|
+
export class DeprecatedCommentScanner {
|
|
5
|
+
static readonly rule = "akan.comment.deprecated-marker";
|
|
6
|
+
static #marker = /^[!?*\s]*(?:(?:TODO|FIXME|XXX|NOTE)\b\s*:?\s*)?@?deprecated\b/i;
|
|
7
|
+
|
|
8
|
+
scan(sourceFiles: SourceFileInfo[]): QualityWarning[] {
|
|
9
|
+
return sourceFiles
|
|
10
|
+
.filter(({ file }) => /^(apps|libs)\//.test(file) && !/\.(test|spec)\.tsx?$/.test(file))
|
|
11
|
+
.flatMap(({ file, sourceFile }) =>
|
|
12
|
+
SourceComments.of(sourceFile)
|
|
13
|
+
.filter((comment) => DeprecatedCommentScanner.#isLooseMarker(comment))
|
|
14
|
+
.map((comment) => ({
|
|
15
|
+
rule: DeprecatedCommentScanner.rule,
|
|
16
|
+
scope: "comment" as const,
|
|
17
|
+
severity: "warning" as const,
|
|
18
|
+
file,
|
|
19
|
+
line: comment.line,
|
|
20
|
+
message: `"${comment.raw.split(/\r?\n/)[0]?.trim().slice(0, 80)}" marks nothing at the use sites; editors strike through only a JSDoc @deprecated tag.`,
|
|
21
|
+
})),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
static #isLooseMarker({ isJsDoc, body }: SourceComment) {
|
|
26
|
+
const text = body.filter((line) => line !== "").join(" ");
|
|
27
|
+
if (isJsDoc && /(^|\s)@deprecated\b/.test(text)) return false;
|
|
28
|
+
return DeprecatedCommentScanner.#marker.test(text);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { EndpointReach } from "./endpointReach";
|
|
4
|
+
|
|
5
|
+
const fileOf = (file: string, content: string) => ({
|
|
6
|
+
file,
|
|
7
|
+
sourceFile: ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe("EndpointReach", () => {
|
|
11
|
+
test("follows an endpoint or a slice through its service's own and other services' methods, not a job", () => {
|
|
12
|
+
const reach = new EndpointReach(
|
|
13
|
+
[
|
|
14
|
+
fileOf(
|
|
15
|
+
"apps/demo/lib/court/court.signal.ts",
|
|
16
|
+
`export class CourtInternal extends internal(srv.court, ({ cron }) => ({
|
|
17
|
+
sweep: cron("0 * * * *").exec(function () { return this.courtService.sweep(); }),
|
|
18
|
+
})) {}
|
|
19
|
+
export class CourtSlice extends slice(srv.court, { guards: { root: Admin } }, (init) => ({
|
|
20
|
+
inOrg: init().exec(function (orgId) { return this.courtService.queryInOrg(orgId); }),
|
|
21
|
+
})) {}
|
|
22
|
+
export class CourtEndpoint extends endpoint(srv.court, ({ mutation }) => ({
|
|
23
|
+
assign: mutation(Boolean).exec(function (id) { return this.courtService.assign(id); }),
|
|
24
|
+
})) {}
|
|
25
|
+
`,
|
|
26
|
+
),
|
|
27
|
+
],
|
|
28
|
+
[
|
|
29
|
+
fileOf(
|
|
30
|
+
"apps/demo/lib/court/court.service.ts",
|
|
31
|
+
`export class CourtService extends serve(db.court, () => ({})) {
|
|
32
|
+
queryInOrg(orgId: string) { return { orgId }; }
|
|
33
|
+
async assign(id: string) { return await this.#pick(id); }
|
|
34
|
+
async #pick(id: string) { return await this.pcService.claim(id); }
|
|
35
|
+
async sweep() { return await this.#pick("all"); }
|
|
36
|
+
}
|
|
37
|
+
`,
|
|
38
|
+
),
|
|
39
|
+
fileOf(
|
|
40
|
+
"libs/shared/lib/_pc/pc.service.ts",
|
|
41
|
+
`export class PcService extends serve("pc", () => ({})) {
|
|
42
|
+
async claim(id: string) { return id; }
|
|
43
|
+
async release(id: string) { return id; }
|
|
44
|
+
}
|
|
45
|
+
`,
|
|
46
|
+
),
|
|
47
|
+
],
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
expect(EndpointReach.serviceOf("libs/shared/lib/_pc/pc.service.ts")).toBe("pc");
|
|
51
|
+
expect(
|
|
52
|
+
["court.queryInOrg", "court.assign", "court.#pick", "pc.claim", "court.sweep", "pc.release"].map((key) => {
|
|
53
|
+
const [service = "", method = ""] = key.split(".");
|
|
54
|
+
return reach.reaches(service, method);
|
|
55
|
+
}),
|
|
56
|
+
).toEqual([true, true, true, true, false, false]);
|
|
57
|
+
});
|
|
58
|
+
});
|
package/endpointReach.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import type { SourceFileInfo } from "./qualityScanner";
|
|
4
|
+
|
|
5
|
+
type ServiceMethod = [service: string, method: string];
|
|
6
|
+
|
|
7
|
+
//? Only `endpoint(…)` and `slice(…)` answer a caller who waits; an `internal(…)` job (cron, interval, queue) works
|
|
8
|
+
//? per item on purpose, so that one failing item leaves the rest done.
|
|
9
|
+
export class EndpointReach {
|
|
10
|
+
static readonly #requestBuilders = new Set(["endpoint", "slice"]);
|
|
11
|
+
readonly #reached = new Set<string>();
|
|
12
|
+
|
|
13
|
+
constructor(
|
|
14
|
+
signalFiles: Pick<SourceFileInfo, "sourceFile">[],
|
|
15
|
+
serviceFiles: Pick<SourceFileInfo, "file" | "sourceFile">[],
|
|
16
|
+
) {
|
|
17
|
+
const methodsByService = new Map<string, Map<string, ts.MethodDeclaration>>();
|
|
18
|
+
for (const { file, sourceFile } of serviceFiles) {
|
|
19
|
+
const methods = methodsByService.get(EndpointReach.serviceOf(file)) ?? new Map<string, ts.MethodDeclaration>();
|
|
20
|
+
for (const cls of sourceFile.statements.filter(ts.isClassDeclaration))
|
|
21
|
+
for (const member of cls.members)
|
|
22
|
+
if (
|
|
23
|
+
ts.isMethodDeclaration(member) &&
|
|
24
|
+
member.body &&
|
|
25
|
+
(ts.isIdentifier(member.name) || ts.isPrivateIdentifier(member.name))
|
|
26
|
+
)
|
|
27
|
+
methods.set(member.name.text, member);
|
|
28
|
+
methodsByService.set(EndpointReach.serviceOf(file), methods);
|
|
29
|
+
}
|
|
30
|
+
const pending: ServiceMethod[] = [];
|
|
31
|
+
const reach = ([service, method]: ServiceMethod) => {
|
|
32
|
+
const key = `${service}.${method}`;
|
|
33
|
+
if (this.#reached.has(key) || !methodsByService.get(service)?.has(method)) return;
|
|
34
|
+
this.#reached.add(key);
|
|
35
|
+
pending.push([service, method]);
|
|
36
|
+
};
|
|
37
|
+
for (const { sourceFile } of signalFiles)
|
|
38
|
+
for (const cls of sourceFile.statements.filter(ts.isClassDeclaration)) {
|
|
39
|
+
const base = cls.heritageClauses?.[0]?.types[0]?.expression;
|
|
40
|
+
if (
|
|
41
|
+
base &&
|
|
42
|
+
ts.isCallExpression(base) &&
|
|
43
|
+
ts.isIdentifier(base.expression) &&
|
|
44
|
+
EndpointReach.#requestBuilders.has(base.expression.text)
|
|
45
|
+
)
|
|
46
|
+
EndpointReach.#serviceCallsIn(base, null).forEach(reach);
|
|
47
|
+
}
|
|
48
|
+
for (let next = pending.pop(); next; next = pending.pop()) {
|
|
49
|
+
const [service, method] = next;
|
|
50
|
+
const declaration = methodsByService.get(service)?.get(method);
|
|
51
|
+
if (declaration) EndpointReach.#serviceCallsIn(declaration, service).forEach(reach);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
static serviceOf(file: string) {
|
|
56
|
+
return path.posix.basename(file).replace(/\.service\.ts$/, "");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
reaches(service: string, method: string) {
|
|
60
|
+
return this.#reached.has(`${service}.${method}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
static #serviceCallsIn(node: ts.Node, ownService: string | null) {
|
|
64
|
+
const calls: ServiceMethod[] = [];
|
|
65
|
+
const visit = (child: ts.Node) => {
|
|
66
|
+
if (ts.isCallExpression(child) && ts.isPropertyAccessExpression(child.expression)) {
|
|
67
|
+
const { expression: owner, name } = child.expression;
|
|
68
|
+
if (owner.kind === ts.SyntaxKind.ThisKeyword && ownService) calls.push([ownService, name.text]);
|
|
69
|
+
else if (ts.isPropertyAccessExpression(owner) && owner.expression.kind === ts.SyntaxKind.ThisKeyword) {
|
|
70
|
+
const field = owner.name.text;
|
|
71
|
+
if (field.length > "Service".length && field.endsWith("Service"))
|
|
72
|
+
calls.push([field.slice(0, -"Service".length), name.text]);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
ts.forEachChild(child, visit);
|
|
76
|
+
};
|
|
77
|
+
visit(node);
|
|
78
|
+
return calls;
|
|
79
|
+
}
|
|
80
|
+
}
|