@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
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { WritePath } from "./writePath";
|
|
3
|
+
|
|
4
|
+
export interface ServiceLoopCall {
|
|
5
|
+
method: string;
|
|
6
|
+
line: number;
|
|
7
|
+
calls: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type NamedMethod = ts.MethodDeclaration & { name: ts.Identifier | ts.PrivateIdentifier };
|
|
11
|
+
|
|
12
|
+
type PerItem = false | "loop" | "callback";
|
|
13
|
+
|
|
14
|
+
//? A counted loop (`for (;;)`, `while`) retries or pages through a query, so only a loop over items is per item.
|
|
15
|
+
export class ServiceLoopCallScanner {
|
|
16
|
+
static readonly #reachingField = /(?:Service|Model)$/;
|
|
17
|
+
static readonly #generatedQuery =
|
|
18
|
+
/^_*(?:get|load|list|find|pick|exists|count|insight|create|update|remove|search)[A-Z]/;
|
|
19
|
+
|
|
20
|
+
scan(sourceFile: ts.SourceFile, answersCaller: (method: string) => boolean): ServiceLoopCall[] {
|
|
21
|
+
return sourceFile.statements.filter(ts.isClassDeclaration).flatMap((cls) => {
|
|
22
|
+
const methods = cls.members.filter(
|
|
23
|
+
(member): member is NamedMethod =>
|
|
24
|
+
ts.isMethodDeclaration(member) &&
|
|
25
|
+
!!member.body &&
|
|
26
|
+
(ts.isIdentifier(member.name) || ts.isPrivateIdentifier(member.name)),
|
|
27
|
+
);
|
|
28
|
+
const declared = new Set(methods.map((method) => method.name.text));
|
|
29
|
+
const reaching = ServiceLoopCallScanner.#reachingMethodsOf(methods, declared);
|
|
30
|
+
const reaches = (call: ts.CallExpression) => ServiceLoopCallScanner.#reaches(call, declared, reaching);
|
|
31
|
+
const ownModel = cls.name?.text.replace(/Service$/, "") ?? "";
|
|
32
|
+
return methods.flatMap((method) => {
|
|
33
|
+
if (!answersCaller(method.name.text)) return [];
|
|
34
|
+
const calls: ts.CallExpression[] = [];
|
|
35
|
+
const visit = (node: ts.Node, perItem: PerItem) => {
|
|
36
|
+
if (
|
|
37
|
+
perItem &&
|
|
38
|
+
ts.isCallExpression(node) &&
|
|
39
|
+
reaches(node) &&
|
|
40
|
+
!(perItem === "callback" && ServiceLoopCallScanner.#isBatchedLoad(node, declared, ownModel))
|
|
41
|
+
)
|
|
42
|
+
calls.push(node);
|
|
43
|
+
if (ts.isForOfStatement(node) || ts.isForInStatement(node)) {
|
|
44
|
+
visit(node.expression, perItem);
|
|
45
|
+
visit(node.statement, "loop");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
ts.forEachChild(node, (child) =>
|
|
49
|
+
visit(child, perItem === "loop" || !WritePath.isPerElementCallback(child) ? perItem : "callback"),
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
visit(method, false);
|
|
53
|
+
const first = calls[0];
|
|
54
|
+
if (!first) return [];
|
|
55
|
+
return [
|
|
56
|
+
{
|
|
57
|
+
method: method.name.text,
|
|
58
|
+
line: sourceFile.getLineAndCharacterOfPosition(first.getStart(sourceFile)).line + 1,
|
|
59
|
+
calls: calls.map((call) => call.expression.getText(sourceFile)),
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
static #reachingMethodsOf(methods: NamedMethod[], declared: Set<string>) {
|
|
67
|
+
const reaching = new Set<string>();
|
|
68
|
+
let grew = true;
|
|
69
|
+
while (grew) {
|
|
70
|
+
grew = false;
|
|
71
|
+
for (const method of methods) {
|
|
72
|
+
const name = method.name.text;
|
|
73
|
+
if (reaching.has(name)) continue;
|
|
74
|
+
let found = false;
|
|
75
|
+
const visit = (node: ts.Node) => {
|
|
76
|
+
if (found) return;
|
|
77
|
+
if (ts.isCallExpression(node) && ServiceLoopCallScanner.#reaches(node, declared, reaching)) found = true;
|
|
78
|
+
else ts.forEachChild(node, visit);
|
|
79
|
+
};
|
|
80
|
+
visit(method);
|
|
81
|
+
if (found) {
|
|
82
|
+
reaching.add(name);
|
|
83
|
+
grew = true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return reaching;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
//? `get<Model>` / `load<Model>` / `load<Model>Many` go through the model's DataLoader, so the calls a per-element
|
|
91
|
+
//? callback issues in one tick are one query; a `for…of` awaiting each item issues them a tick apart, one by one.
|
|
92
|
+
static #isBatchedLoad(call: ts.CallExpression, declared: Set<string>, ownModel: string) {
|
|
93
|
+
const callee = call.expression;
|
|
94
|
+
if (!ts.isPropertyAccessExpression(callee)) return false;
|
|
95
|
+
const model = ServiceLoopCallScanner.#loadedModelOf(callee, declared, ownModel);
|
|
96
|
+
if (!model) return false;
|
|
97
|
+
const className = `${model.charAt(0).toUpperCase()}${model.slice(1)}`;
|
|
98
|
+
return [`get${className}`, `load${className}`, `load${className}Many`].includes(callee.name.text);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
static #loadedModelOf(callee: ts.PropertyAccessExpression, declared: Set<string>, ownModel: string) {
|
|
102
|
+
const owner = callee.expression;
|
|
103
|
+
if (owner.kind === ts.SyntaxKind.ThisKeyword) return declared.has(callee.name.text) ? null : ownModel;
|
|
104
|
+
if (!ts.isPropertyAccessExpression(owner) || owner.expression.kind !== ts.SyntaxKind.ThisKeyword) return null;
|
|
105
|
+
return owner.name.text.match(/^(\w+)Model$/)?.[1] ?? null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
static #reaches(call: ts.CallExpression, declared: Set<string>, reaching: Set<string>) {
|
|
109
|
+
const callee = call.expression;
|
|
110
|
+
if (!ts.isPropertyAccessExpression(callee)) return false;
|
|
111
|
+
const owner = callee.expression;
|
|
112
|
+
if (owner.kind === ts.SyntaxKind.ThisKeyword) {
|
|
113
|
+
const name = callee.name.text;
|
|
114
|
+
return declared.has(name) ? reaching.has(name) : ServiceLoopCallScanner.#generatedQuery.test(name);
|
|
115
|
+
}
|
|
116
|
+
return (
|
|
117
|
+
ts.isPropertyAccessExpression(owner) &&
|
|
118
|
+
owner.expression.kind === ts.SyntaxKind.ThisKeyword &&
|
|
119
|
+
ServiceLoopCallScanner.#reachingField.test(owner.name.text)
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { ShellOnlyEnv } from "./shellOnlyEnv";
|
|
6
|
+
|
|
7
|
+
const key = "AKAN_SHELL_ONLY_TEST_KEY";
|
|
8
|
+
const shellValue = "from-shell";
|
|
9
|
+
|
|
10
|
+
const inRepo = (files: Record<string, string>, run: (env: ShellOnlyEnv<typeof key>) => void) => {
|
|
11
|
+
const repo = mkdtempSync(path.join(tmpdir(), "akan-shell-only-env-"));
|
|
12
|
+
const cwd = process.cwd();
|
|
13
|
+
try {
|
|
14
|
+
for (const [name, content] of Object.entries(files)) writeFileSync(path.join(repo, name), content);
|
|
15
|
+
process.chdir(repo);
|
|
16
|
+
process.env[key] = shellValue;
|
|
17
|
+
run(new ShellOnlyEnv([key]));
|
|
18
|
+
} finally {
|
|
19
|
+
process.chdir(cwd);
|
|
20
|
+
delete process.env[key];
|
|
21
|
+
rmSync(repo, { recursive: true, force: true });
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe("ShellOnlyEnv", () => {
|
|
26
|
+
test.each([
|
|
27
|
+
".env",
|
|
28
|
+
".env.local",
|
|
29
|
+
".env.development",
|
|
30
|
+
".env.development.local",
|
|
31
|
+
".env.production",
|
|
32
|
+
".env.production.local",
|
|
33
|
+
".env.test",
|
|
34
|
+
".env.test.local",
|
|
35
|
+
])("ignores a key that %s names", (file) => {
|
|
36
|
+
inRepo({ [file]: `${key}=placeholder\n` }, (env) => {
|
|
37
|
+
expect(env.get(key)).toBeUndefined();
|
|
38
|
+
expect(env.ignored()).toEqual([
|
|
39
|
+
`${key} is set in ${file}, which the repository controls, so it is ignored. Export it in your shell instead.`,
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test.each([
|
|
45
|
+
`${key}=placeholder`,
|
|
46
|
+
`export ${key}=placeholder`,
|
|
47
|
+
` ${key} = placeholder`,
|
|
48
|
+
`${key}: placeholder`,
|
|
49
|
+
`export ${key}: placeholder`,
|
|
50
|
+
`${key}\t:\tplaceholder`,
|
|
51
|
+
`${key}:`,
|
|
52
|
+
`${key}=placeholder\r`,
|
|
53
|
+
])("ignores a key set as %j", (line) => {
|
|
54
|
+
inRepo({ ".env": `OTHER_KEY=placeholder\n${line}\n` }, (env) => {
|
|
55
|
+
expect(env.get(key)).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("reads the shell value when no dotenv file names the key", () => {
|
|
60
|
+
inRepo({ ".env": `OTHER_KEY=placeholder\n# ${key}=placeholder\n${key}_SUFFIX=placeholder\n` }, (env) => {
|
|
61
|
+
expect(env.get(key)).toBe(shellValue);
|
|
62
|
+
expect(env.ignored()).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
package/shellOnlyEnv.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
// Bun loads the ones NODE_ENV picks from the working directory before code runs, so a cloned repo's file can set them.
|
|
5
|
+
const dotenvFiles = [
|
|
6
|
+
".env",
|
|
7
|
+
".env.local",
|
|
8
|
+
".env.development",
|
|
9
|
+
".env.development.local",
|
|
10
|
+
".env.production",
|
|
11
|
+
".env.production.local",
|
|
12
|
+
".env.test",
|
|
13
|
+
".env.test.local",
|
|
14
|
+
];
|
|
15
|
+
// Bun also reads `KEY: value` lines.
|
|
16
|
+
const assignedKey = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*[=:]/;
|
|
17
|
+
|
|
18
|
+
// Judged by presence, not value: a comparison would trust whatever a parser difference with Bun's reader lets through.
|
|
19
|
+
export class ShellOnlyEnv<Key extends string> {
|
|
20
|
+
readonly #keys: ReadonlySet<string>;
|
|
21
|
+
#cache: { cwd: string; found: Map<string, string> } | undefined;
|
|
22
|
+
|
|
23
|
+
constructor(keys: readonly Key[]) {
|
|
24
|
+
this.#keys = new Set(keys);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get(key: Key): string | undefined {
|
|
28
|
+
if (this.#found().has(key)) return undefined;
|
|
29
|
+
return process.env[key];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
ignored() {
|
|
33
|
+
return [...this.#found()].map(
|
|
34
|
+
([key, file]) =>
|
|
35
|
+
`${key} is set in ${file}, which the repository controls, so it is ignored. Export it in your shell instead.`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#found() {
|
|
40
|
+
const cwd = process.cwd();
|
|
41
|
+
if (this.#cache?.cwd === cwd) return this.#cache.found;
|
|
42
|
+
const found = new Map<string, string>();
|
|
43
|
+
for (const name of dotenvFiles) {
|
|
44
|
+
const file = path.join(cwd, name);
|
|
45
|
+
if (!existsSync(file)) continue;
|
|
46
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
47
|
+
const key = line.match(assignedKey)?.[1];
|
|
48
|
+
if (key && this.#keys.has(key) && !found.has(key)) found.set(key, name);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
this.#cache = { cwd, found };
|
|
52
|
+
return found;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -110,6 +110,21 @@ export class ItemEndpoint extends endpoint(srv.item, ({ query, mutation }) => ({
|
|
|
110
110
|
expect(index.isMutation("apps/third/page/x.tsx", "approveTicket")).toBe(false);
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
test("reads a lib page's call by its own lib's endpoint before another lib's of that name", () => {
|
|
114
|
+
const shared = fileOf(
|
|
115
|
+
"libs/shared/lib/_shared/shared.signal.ts",
|
|
116
|
+
`export class SharedEndpoint extends endpoint(srv.shared, ({ mutation }) => ({ syncFeed: mutation(Boolean, {}).exec(() => true) })) {}`,
|
|
117
|
+
);
|
|
118
|
+
const social = fileOf(
|
|
119
|
+
"libs/social/lib/_social/social.signal.ts",
|
|
120
|
+
`export class SocialEndpoint extends endpoint(srv.social, ({ query }) => ({ syncFeed: query(Boolean, {}).exec(() => true) })) {}`,
|
|
121
|
+
);
|
|
122
|
+
const index = new SignalMutationIndex([shared, social]);
|
|
123
|
+
expect(index.isMutation("libs/social/page/x.tsx", "syncFeed")).toBe(false);
|
|
124
|
+
expect(index.policyIndexOf("libs/social/page/x.tsx", "syncFeed")).toBeNull();
|
|
125
|
+
expect(index.isMutation("libs/shared/page/x.tsx", "syncFeed")).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
113
128
|
test("ignores signal tests and specs", () => {
|
|
114
129
|
const spec = fileOf(
|
|
115
130
|
"apps/demo/lib/ticket/ticket.signal.spec.ts",
|
package/signalMutationIndex.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createAst, createFactReader, type FileFacts } from "@akanjs/language";
|
|
2
|
+
import { modelNames } from "akanjs/common";
|
|
1
3
|
import ts from "typescript";
|
|
2
4
|
import type { SourceFileInfo } from "./qualityScanner";
|
|
3
5
|
|
|
@@ -8,8 +10,14 @@ interface EndpointShape {
|
|
|
8
10
|
|
|
9
11
|
export class SignalMutationIndex {
|
|
10
12
|
//? As the client generates them: `update<Model>(id, data, option?)`, `add<Model>Files(files, parentId?, option?)`.
|
|
11
|
-
static #generatedArgCounts =
|
|
12
|
-
|
|
13
|
+
static #generatedArgCounts = [
|
|
14
|
+
["createModel", 1],
|
|
15
|
+
["updateModel", 2],
|
|
16
|
+
["removeModel", 1],
|
|
17
|
+
["mergeModel", 2],
|
|
18
|
+
["addModelFiles", 2],
|
|
19
|
+
] as const;
|
|
20
|
+
static #reader = createFactReader(ts, createAst(ts));
|
|
13
21
|
readonly #shapesByOwner = new Map<string, Map<string, EndpointShape>>();
|
|
14
22
|
|
|
15
23
|
constructor(sourceFiles: SourceFileInfo[]) {
|
|
@@ -17,7 +25,7 @@ export class SignalMutationIndex {
|
|
|
17
25
|
if (!/^(apps|libs)\/[^/]+\/lib\/.+\.signal\.ts$/.test(file)) continue;
|
|
18
26
|
const owner = file.split("/").slice(0, 2).join("/");
|
|
19
27
|
const shapes = this.#shapesByOwner.get(owner) ?? new Map<string, EndpointShape>();
|
|
20
|
-
SignalMutationIndex.#collect(sourceFile, shapes);
|
|
28
|
+
SignalMutationIndex.#collect(SignalMutationIndex.#reader.read(sourceFile, owner), shapes);
|
|
21
29
|
this.#shapesByOwner.set(owner, shapes);
|
|
22
30
|
}
|
|
23
31
|
}
|
|
@@ -34,7 +42,7 @@ export class SignalMutationIndex {
|
|
|
34
42
|
|
|
35
43
|
#shapeOf(consumerFile: string, name: string) {
|
|
36
44
|
const [root, owner] = consumerFile.split("/");
|
|
37
|
-
const own =
|
|
45
|
+
const own = this.#shapesByOwner.get(`${root}/${owner}`)?.get(name);
|
|
38
46
|
if (own) return own;
|
|
39
47
|
for (const [key, shapes] of this.#shapesByOwner) {
|
|
40
48
|
const shape = shapes.get(name);
|
|
@@ -43,85 +51,18 @@ export class SignalMutationIndex {
|
|
|
43
51
|
return undefined;
|
|
44
52
|
}
|
|
45
53
|
|
|
46
|
-
static #collect(
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
ts.forEachChild(node, visit);
|
|
53
|
-
};
|
|
54
|
-
visit(sourceFile);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
static #collectGenerated(slice: ts.CallExpression, kinds: Map<string, EndpointShape>) {
|
|
58
|
-
let service: ts.Expression | undefined = slice.arguments[0];
|
|
59
|
-
while (service && ts.isCallExpression(service) && ts.isPropertyAccessExpression(service.expression))
|
|
60
|
-
service = service.expression.expression;
|
|
61
|
-
if (!service || !ts.isPropertyAccessExpression(service) || !SignalMutationIndex.#isNamed(service.expression, "srv"))
|
|
62
|
-
return;
|
|
63
|
-
const model = service.name.text;
|
|
64
|
-
const capModel = `${model.charAt(0).toUpperCase()}${model.slice(1)}`;
|
|
65
|
-
for (const [verb, argCount] of Object.entries(SignalMutationIndex.#generatedArgCounts))
|
|
66
|
-
kinds.set(`${verb}${capModel}`, { kind: "mutation", argCount });
|
|
67
|
-
kinds.set(`add${capModel}Files`, { kind: "mutation", argCount: 2 });
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
static #collectEndpoints(endpoint: ts.CallExpression, kinds: Map<string, EndpointShape>) {
|
|
71
|
-
const builder = endpoint.arguments[1];
|
|
72
|
-
if (!builder || (!ts.isArrowFunction(builder) && !ts.isFunctionExpression(builder))) return;
|
|
73
|
-
const map = SignalMutationIndex.#returnedObjectOf(builder);
|
|
74
|
-
if (!map) return;
|
|
75
|
-
const verbOf = SignalMutationIndex.#verbReaderOf(builder.parameters[0]);
|
|
76
|
-
for (const property of map.properties) {
|
|
77
|
-
if (!ts.isPropertyAssignment(property)) continue;
|
|
78
|
-
if (!ts.isIdentifier(property.name) && !ts.isStringLiteral(property.name)) continue;
|
|
79
|
-
const { root, argCount } = SignalMutationIndex.#chainOf(property.initializer);
|
|
80
|
-
const verb = verbOf(root);
|
|
81
|
-
//? A socket message writes with the visitor's credential as a mutation does, and the client refuses it alike.
|
|
82
|
-
const kind = verb === "message" ? "mutation" : verb;
|
|
83
|
-
if (kind === "query" || kind === "mutation") kinds.set(property.name.text, { kind, argCount });
|
|
54
|
+
static #collect(facts: FileFacts, shapes: Map<string, EndpointShape>) {
|
|
55
|
+
for (const { model } of facts.slices) {
|
|
56
|
+
if (!model) continue;
|
|
57
|
+
const names = modelNames(model);
|
|
58
|
+
for (const [role, argCount] of SignalMutationIndex.#generatedArgCounts)
|
|
59
|
+
shapes.set(names[role], { kind: "mutation", argCount });
|
|
84
60
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
while (body && ts.isParenthesizedExpression(body)) body = body.expression;
|
|
91
|
-
return body && ts.isObjectLiteralExpression(body) ? body : null;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
static #verbReaderOf(parameter: ts.ParameterDeclaration | undefined) {
|
|
95
|
-
const verbs = new Map<string, string>();
|
|
96
|
-
if (parameter && ts.isObjectBindingPattern(parameter.name))
|
|
97
|
-
for (const element of parameter.name.elements) {
|
|
98
|
-
const verb = element.propertyName ?? element.name;
|
|
99
|
-
if (ts.isIdentifier(verb) && ts.isIdentifier(element.name)) verbs.set(element.name.text, verb.text);
|
|
61
|
+
for (const { keys } of facts.endpoints)
|
|
62
|
+
for (const { name, verb, argCount } of keys) {
|
|
63
|
+
//? A socket message writes with the visitor's credential as a mutation does, and the client refuses it alike.
|
|
64
|
+
const kind = verb === "message" ? "mutation" : verb;
|
|
65
|
+
if (kind === "query" || kind === "mutation") shapes.set(name, { kind, argCount });
|
|
100
66
|
}
|
|
101
|
-
const holder = parameter && ts.isIdentifier(parameter.name) ? parameter.name.text : null;
|
|
102
|
-
return (callee: ts.Expression | undefined) => {
|
|
103
|
-
if (callee && ts.isIdentifier(callee)) return verbs.get(callee.text);
|
|
104
|
-
if (callee && ts.isPropertyAccessExpression(callee) && SignalMutationIndex.#isNamed(callee.expression, holder))
|
|
105
|
-
return callee.name.text;
|
|
106
|
-
return undefined;
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
static #isNamed(expression: ts.Expression, name: string | null) {
|
|
111
|
-
return ts.isIdentifier(expression) && expression.text === name;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
static #chainOf(initializer: ts.Expression) {
|
|
115
|
-
let root = initializer;
|
|
116
|
-
let argCount = 0;
|
|
117
|
-
while (
|
|
118
|
-
ts.isCallExpression(root) &&
|
|
119
|
-
ts.isPropertyAccessExpression(root.expression) &&
|
|
120
|
-
ts.isCallExpression(root.expression.expression)
|
|
121
|
-
) {
|
|
122
|
-
if (SignalMutationIndex.#argMethods.has(root.expression.name.text)) argCount += 1;
|
|
123
|
-
root = root.expression.expression;
|
|
124
|
-
}
|
|
125
|
-
return { root: ts.isCallExpression(root) ? root.expression : undefined, argCount };
|
|
126
67
|
}
|
|
127
68
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { SourceComments } from "./sourceComments";
|
|
4
|
+
|
|
5
|
+
const commentsOf = (content: string) =>
|
|
6
|
+
SourceComments.of(ts.createSourceFile("demo.tsx", content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)).map(
|
|
7
|
+
({ line, body, isJsDoc }) => ({ line, body, isJsDoc }),
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
describe("SourceComments", () => {
|
|
11
|
+
test("finds leading, trailing, empty-block and end-of-file comments", () => {
|
|
12
|
+
expect(
|
|
13
|
+
commentsOf(
|
|
14
|
+
["// leading", "const a = 1; // trailing", "const f = () => {", " /* inside */", "};", "// last", ""].join(
|
|
15
|
+
"\n",
|
|
16
|
+
),
|
|
17
|
+
),
|
|
18
|
+
).toEqual([
|
|
19
|
+
{ line: 1, body: ["leading"], isJsDoc: false },
|
|
20
|
+
{ line: 2, body: ["trailing"], isJsDoc: false },
|
|
21
|
+
{ line: 4, body: ["inside"], isJsDoc: false },
|
|
22
|
+
{ line: 6, body: ["last"], isJsDoc: false },
|
|
23
|
+
]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("ignores comment syntax inside strings, templates, regexes and JSX text", () => {
|
|
27
|
+
expect(
|
|
28
|
+
commentsOf(
|
|
29
|
+
[
|
|
30
|
+
'const url = "https://host//path";',
|
|
31
|
+
`const tpl = \`a // b \${url} // c\`;`,
|
|
32
|
+
"const re = /\\/\\/x/;",
|
|
33
|
+
'export const C = () => <div title="//x">// text {/* real */}</div>;',
|
|
34
|
+
"",
|
|
35
|
+
].join("\n"),
|
|
36
|
+
),
|
|
37
|
+
).toEqual([{ line: 4, body: ["real"], isJsDoc: false }]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("strips block delimiters and marks a JSDoc", () => {
|
|
41
|
+
expect(
|
|
42
|
+
commentsOf(["/**", " * Summary", " * @deprecated use b", " */", "export const a = 1;", ""].join("\n")),
|
|
43
|
+
).toEqual([{ line: 1, body: ["", "Summary", "@deprecated use b", ""], isJsDoc: true }]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
|
|
3
|
+
export interface SourceComment {
|
|
4
|
+
raw: string;
|
|
5
|
+
line: number;
|
|
6
|
+
isJsDoc: boolean;
|
|
7
|
+
body: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class SourceComments {
|
|
11
|
+
static #scanner = ts.createScanner(ts.ScriptTarget.Latest, false);
|
|
12
|
+
|
|
13
|
+
static of(sourceFile: ts.SourceFile): SourceComment[] {
|
|
14
|
+
const comments: SourceComment[] = [];
|
|
15
|
+
const scanGap = (start: number, end: number) => {
|
|
16
|
+
if (end <= start) return;
|
|
17
|
+
const scanner = SourceComments.#scanner;
|
|
18
|
+
scanner.setText(sourceFile.text, start, end - start);
|
|
19
|
+
for (let kind = scanner.scan(); kind !== ts.SyntaxKind.EndOfFileToken; kind = scanner.scan()) {
|
|
20
|
+
if (kind !== ts.SyntaxKind.SingleLineCommentTrivia && kind !== ts.SyntaxKind.MultiLineCommentTrivia) continue;
|
|
21
|
+
comments.push(SourceComments.#commentOf(sourceFile, scanner.getTokenStart(), scanner.getTokenText()));
|
|
22
|
+
}
|
|
23
|
+
scanner.setText(undefined);
|
|
24
|
+
};
|
|
25
|
+
//? Only the trivia between AST children is scanned: string, template, regex and JSX text tokens can hold `//`.
|
|
26
|
+
const visit = (node: ts.Node) => {
|
|
27
|
+
if (node.kind === ts.SyntaxKind.JsxText) return;
|
|
28
|
+
if (ts.isToken(node)) {
|
|
29
|
+
scanGap(node.pos, node.getStart(sourceFile));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const children: ts.Node[] = [];
|
|
33
|
+
ts.forEachChild(
|
|
34
|
+
node,
|
|
35
|
+
(child) => {
|
|
36
|
+
children.push(child);
|
|
37
|
+
},
|
|
38
|
+
(nodes) => {
|
|
39
|
+
children.push(...nodes);
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
let cursor = node.pos;
|
|
43
|
+
for (const child of children) {
|
|
44
|
+
scanGap(cursor, child.pos);
|
|
45
|
+
visit(child);
|
|
46
|
+
cursor = Math.max(cursor, child.end);
|
|
47
|
+
}
|
|
48
|
+
scanGap(cursor, node.end);
|
|
49
|
+
};
|
|
50
|
+
visit(sourceFile);
|
|
51
|
+
return comments;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
static #commentOf(sourceFile: ts.SourceFile, pos: number, raw: string): SourceComment {
|
|
55
|
+
return {
|
|
56
|
+
raw,
|
|
57
|
+
line: sourceFile.getLineAndCharacterOfPosition(pos).line + 1,
|
|
58
|
+
isJsDoc: raw.startsWith("/**") && !raw.startsWith("/**/"),
|
|
59
|
+
body: SourceComments.#bodyOf(raw),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
static #bodyOf(raw: string) {
|
|
64
|
+
if (raw.startsWith("//")) return [raw.replace(/^\/\/+/, "").trim()];
|
|
65
|
+
return raw
|
|
66
|
+
.slice(2, raw.endsWith("*/") ? -2 : undefined)
|
|
67
|
+
.split(/\r?\n/)
|
|
68
|
+
.map((line) => line.replace(/^\s*\*+/, "").trim());
|
|
69
|
+
}
|
|
70
|
+
}
|
package/sshHostKeys.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { ConfigLock } from "./cloud/configLock";
|
|
5
|
+
import { FileSys } from "./fileSys";
|
|
6
|
+
|
|
7
|
+
type KnownHosts = Record<string, string>;
|
|
8
|
+
|
|
9
|
+
export interface HostKeyVerdict {
|
|
10
|
+
trusted: boolean;
|
|
11
|
+
firstUse: boolean;
|
|
12
|
+
fingerprint: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class SshHostKeys {
|
|
16
|
+
static defaultPath() {
|
|
17
|
+
return path.join(Bun.env.HOME ?? Bun.env.USERPROFILE ?? "", ".akan", "ssh_known_hosts.json");
|
|
18
|
+
}
|
|
19
|
+
static fingerprintOf(key: Buffer) {
|
|
20
|
+
return `SHA256:${createHash("sha256").update(key).digest("base64").replace(/=+$/, "")}`;
|
|
21
|
+
}
|
|
22
|
+
static parsePinned(value: string | undefined) {
|
|
23
|
+
return (value ?? "")
|
|
24
|
+
.split(",")
|
|
25
|
+
.map((fingerprint) => fingerprint.trim())
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.map((fingerprint) => (fingerprint.startsWith("SHA256:") ? fingerprint : `SHA256:${fingerprint}`));
|
|
28
|
+
}
|
|
29
|
+
readonly filePath: string;
|
|
30
|
+
constructor(filePath: string = SshHostKeys.defaultPath()) {
|
|
31
|
+
this.filePath = filePath;
|
|
32
|
+
}
|
|
33
|
+
//? Pinned fingerprints decide alone; without them the first key a host shows is kept and any other is refused.
|
|
34
|
+
async verify(hostId: string, key: Buffer, pinned: string[] = []): Promise<HostKeyVerdict> {
|
|
35
|
+
const fingerprint = SshHostKeys.fingerprintOf(key);
|
|
36
|
+
if (pinned.length) return { trusted: pinned.includes(fingerprint), firstUse: false, fingerprint };
|
|
37
|
+
return await ConfigLock.run(`${this.filePath}.lock`, async () => {
|
|
38
|
+
const known = await this.#read();
|
|
39
|
+
const stored = known[hostId];
|
|
40
|
+
if (stored) return { trusted: stored === fingerprint, firstUse: false, fingerprint };
|
|
41
|
+
await this.#write({ ...known, [hostId]: fingerprint });
|
|
42
|
+
return { trusted: true, firstUse: true, fingerprint };
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
async #read(): Promise<KnownHosts> {
|
|
46
|
+
return (await FileSys.fileExists(this.filePath)) ? await FileSys.readJson<KnownHosts>(this.filePath) : {};
|
|
47
|
+
}
|
|
48
|
+
async #write(known: KnownHosts) {
|
|
49
|
+
await mkdir(path.dirname(this.filePath), { recursive: true, mode: 0o700 });
|
|
50
|
+
const temp = `${this.filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
51
|
+
try {
|
|
52
|
+
await writeFile(temp, JSON.stringify(known, null, 2), { mode: 0o600 });
|
|
53
|
+
await FileSys.replace(temp, this.filePath);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
await rm(temp, { force: true });
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { SignalMutationIndex } from "./signalMutationIndex";
|
|
4
|
+
import { StoreAccessScanner } from "./storeAccessScanner";
|
|
5
|
+
import { WritingActionIndex } from "./writingActionIndex";
|
|
6
|
+
|
|
7
|
+
const sourceOf = (file: string, content: string) =>
|
|
8
|
+
ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
9
|
+
|
|
10
|
+
const storeFile = "apps/demo/lib/board/board.store.ts";
|
|
11
|
+
const signalFile = "apps/demo/lib/boardPin/boardPin.signal.ts";
|
|
12
|
+
const signal = sourceOf(
|
|
13
|
+
signalFile,
|
|
14
|
+
"export class BoardPinSlice extends slice(srv.boardPin, { guards: { root: Admin, cru: User } }, () => ({})) {}\n",
|
|
15
|
+
);
|
|
16
|
+
const index = new WritingActionIndex(
|
|
17
|
+
new SignalMutationIndex([{ file: signalFile, absolutePath: "", content: "", sourceFile: signal }]),
|
|
18
|
+
[
|
|
19
|
+
{
|
|
20
|
+
file: storeFile,
|
|
21
|
+
sourceFile: sourceOf(
|
|
22
|
+
storeFile,
|
|
23
|
+
`import { fetch, sig } from "../useClient";
|
|
24
|
+
export class BoardStore extends store(sig.board, () => ({})) {
|
|
25
|
+
async addPinOnCanvas(boardId: string) {
|
|
26
|
+
await fetch.createBoardPin({ board: boardId });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
`,
|
|
30
|
+
),
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const measure = (content: string) =>
|
|
36
|
+
new StoreAccessScanner(index).measure(sourceOf("apps/demo/lib/board/Board.Zone.tsx", content));
|
|
37
|
+
|
|
38
|
+
describe("StoreAccessScanner", () => {
|
|
39
|
+
test("counts every read, action, selector, slice and agent hook a component reaches the store through", () => {
|
|
40
|
+
const counts = measure(`"use client";
|
|
41
|
+
import { st as store } from "@apps/demo/client";
|
|
42
|
+
|
|
43
|
+
export const Canvas = () => {
|
|
44
|
+
const board = store.use.board();
|
|
45
|
+
const pins = store.slice.boardInSelf.use.boardList();
|
|
46
|
+
const zoom = store.sel((state) => state.zoom);
|
|
47
|
+
const latest = store.ref((state) => state.board);
|
|
48
|
+
const move = store.tool("movePin").exec(() => store.do.setZoom(1));
|
|
49
|
+
return <div onClick={() => void store.do.addPinOnCanvas(board.id)}>{pins.length}{zoom}{latest.current?.id}{String(move)}</div>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const Empty = () => null;
|
|
53
|
+
`);
|
|
54
|
+
|
|
55
|
+
expect(counts).toEqual([
|
|
56
|
+
{ component: "Canvas", line: 4, accessCount: 7 },
|
|
57
|
+
{ component: "Empty", line: 13, accessCount: 0 },
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("leaves out a generated form setter bound to its control, but not a writing action named like one", () => {
|
|
62
|
+
const counts = measure(`"use client";
|
|
63
|
+
import { st } from "@apps/demo/client";
|
|
64
|
+
|
|
65
|
+
export const General = () => {
|
|
66
|
+
const boardForm = st.use.boardForm();
|
|
67
|
+
return (
|
|
68
|
+
<form>
|
|
69
|
+
<input value={boardForm.name} onChange={(e) => st.do.setNameOnBoard(e.target.value)} />
|
|
70
|
+
<input onChange={st.do.addTagOnBoard} />
|
|
71
|
+
<input onChange={(e) => st.do.writeOnBoard("meta.title", e.target.value)} />
|
|
72
|
+
<button type="button" onClick={() => void st.do.addPinOnCanvas(boardForm.id)} />
|
|
73
|
+
</form>
|
|
74
|
+
);
|
|
75
|
+
};
|
|
76
|
+
`);
|
|
77
|
+
|
|
78
|
+
expect(counts).toEqual([{ component: "General", line: 4, accessCount: 2 }]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("measures nothing in a file that does not import st", () => {
|
|
82
|
+
expect(measure("export const Plain = () => st.use.board();\n")).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
});
|