@akanjs/devkit 3.1.1-rc.8 → 3.1.1
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 +30 -0
- package/akanConfig/akanConfig.ts +10 -0
- package/akanConfig/productionLockfile.test.ts +31 -0
- package/akanConfig/productionLockfile.ts +2 -0
- package/akanContext.ts +129 -4
- package/applicationBuildRunner.test.ts +17 -0
- package/applicationBuildRunner.ts +3 -1
- package/biome.base.json +4 -1
- package/cloud/cloudUpdates.ts +3 -2
- package/codeAgent/agent/CodeAgent.ts +4 -0
- package/codeAgent/agent/SubagentPool.ts +5 -0
- package/codeAgent/agent/akanSystemPrompt.ts +1 -1
- package/codeAgent/agent/codeAgent.test.ts +41 -2
- 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 +36 -0
- package/codeAgent/skills/akan-code/SKILL.md +1 -0
- package/codeAgent/skills/akan-debug/SKILL.md +40 -6
- package/codeAgent/skills/akan-deps/SKILL.md +1 -2
- 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 +38 -0
- package/codeAgent/skills/akan-runtime/SKILL.md +40 -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/akanCodePlugins.test.ts +60 -58
- 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/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/executors.test.ts +0 -4
- package/executors.ts +18 -2
- package/index.ts +1 -0
- package/integration/devStability.integration.test.ts +9 -5
- 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/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 +94 -23
- package/mobile/desktopRelease.ts +83 -45
- package/mobile/nativeApi.test.ts +4 -1
- package/mobile/nativeApi.ts +3 -3
- package/mobile/nativeApp.test.ts +2 -2
- package/mobile/nativeApp.ts +18 -11
- package/mobile/nativeConfig.test.ts +16 -8
- package/mobile/nativeConfig.ts +1 -1
- package/mobile/updateUploader.test.ts +34 -0
- package/mobile/updateUploader.ts +9 -6
- package/package.json +3 -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/signalMutationIndex.test.ts +15 -0
- package/signalMutationIndex.ts +1 -1
- package/sourceComments.test.ts +45 -0
- package/sourceComments.ts +70 -0
- package/storeAccessScanner.test.ts +84 -0
- package/storeAccessScanner.ts +71 -0
- package/storeSource.ts +159 -0
- package/storeWriteScanner.test.ts +292 -0
- package/storeWriteScanner.ts +50 -0
- package/testOnlyExportScanner.test.ts +83 -0
- package/testOnlyExportScanner.ts +124 -0
- 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 +519 -0
- package/workflow/mutationSource.test.ts +108 -0
- package/workflow/mutationSource.ts +219 -0
- package/workflow/plan.ts +54 -0
- package/workflow/render.ts +19 -4
- package/workflow/source.test.ts +185 -0
- package/workflow/source.ts +151 -24
- 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,130 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { SizeViolation } from "./maintainabilityContract";
|
|
3
|
+
import { MaintainabilityReport } from "./maintainabilityReport";
|
|
4
|
+
|
|
5
|
+
const fileOf = (file: string, lineCount = 450): SizeViolation => ({
|
|
6
|
+
kind: "size",
|
|
7
|
+
rule: "akan.size.file",
|
|
8
|
+
key: file,
|
|
9
|
+
file,
|
|
10
|
+
line: 1,
|
|
11
|
+
lineCount,
|
|
12
|
+
maxLines: 400,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const fixLinesOf = (message: string) => message.split("How to fix:\n")[1]?.split("\n") ?? [];
|
|
16
|
+
|
|
17
|
+
describe("MaintainabilityReport", () => {
|
|
18
|
+
test("gives a client file, a ui/ or webkit/ file, a long component and a busy one the same ordered paths", () => {
|
|
19
|
+
const message = MaintainabilityReport.describe("apps/demo", [
|
|
20
|
+
fileOf("lib/court/Court.Zone.tsx"),
|
|
21
|
+
fileOf("ui/Board.tsx"),
|
|
22
|
+
fileOf("webkit/useBoard.tsx"),
|
|
23
|
+
{
|
|
24
|
+
...fileOf("lib/court/Court.Zone.tsx", 300),
|
|
25
|
+
rule: "akan.size.component",
|
|
26
|
+
key: "lib/court/Court.Zone.tsx#Canvas",
|
|
27
|
+
line: 4,
|
|
28
|
+
maxLines: 250,
|
|
29
|
+
component: "Canvas",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
kind: "storeAccess",
|
|
33
|
+
rule: "akan.component.store-access",
|
|
34
|
+
key: "lib/court/Court.Zone.tsx#Canvas",
|
|
35
|
+
file: "lib/court/Court.Zone.tsx",
|
|
36
|
+
line: 4,
|
|
37
|
+
component: "Canvas",
|
|
38
|
+
accessCount: 20,
|
|
39
|
+
maxAccesses: 16,
|
|
40
|
+
},
|
|
41
|
+
]);
|
|
42
|
+
const [fix = "", ...rest] = fixLinesOf(message);
|
|
43
|
+
const order = ["mode/kind table", "webkit/use<X>.tsx", "`common/`", "add-mutation", "`lib/<sub>`"].map((path) =>
|
|
44
|
+
fix.indexOf(path),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
expect(rest).toEqual([]);
|
|
48
|
+
expect(fix).toStartWith(
|
|
49
|
+
" *.Zone.tsx, ui/**, webkit/**, component or hook, component or hook reaching the store often: ",
|
|
50
|
+
);
|
|
51
|
+
expect(order.every((idx) => idx > 0)).toBe(true);
|
|
52
|
+
expect([...order].sort((a, b) => a - b)).toEqual(order);
|
|
53
|
+
expect(fix).toContain('`{ [kind in cnst.X["value"]]: … }`');
|
|
54
|
+
expect(fix).toContain("akan workflow plan create-interaction");
|
|
55
|
+
expect(fix).toContain("never model-bound code moved into `ui/`");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("names a write sent from the wrong place, a call per item and a raised entry with their own fixes", () => {
|
|
59
|
+
const message = MaintainabilityReport.describe("apps/demo", [
|
|
60
|
+
{
|
|
61
|
+
kind: "baselineGrowth",
|
|
62
|
+
rule: "akan.baseline.growth",
|
|
63
|
+
key: "lib/court/court.store.ts#assign",
|
|
64
|
+
file: "akan.baseline.json",
|
|
65
|
+
line: 5,
|
|
66
|
+
debtRule: "akan.store.multi-write",
|
|
67
|
+
count: 3,
|
|
68
|
+
committed: 2,
|
|
69
|
+
reference: "3f2a9c1d0b7e",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
kind: "writePlacement",
|
|
73
|
+
rule: "akan.write.placement",
|
|
74
|
+
key: "webkit/useBoard.tsx",
|
|
75
|
+
file: "webkit/useBoard.tsx",
|
|
76
|
+
line: 7,
|
|
77
|
+
writes: ["st.do.removeBoard", "fetch.placeBoard"],
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
kind: "loopCall",
|
|
81
|
+
rule: "akan.service.call-in-loop",
|
|
82
|
+
key: "lib/court/court.service.ts#syncAll",
|
|
83
|
+
file: "lib/court/court.service.ts",
|
|
84
|
+
line: 12,
|
|
85
|
+
owner: "syncAll",
|
|
86
|
+
calls: ["this.pcService.getPc"],
|
|
87
|
+
recorded: 0,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
kind: "multiWrite",
|
|
91
|
+
rule: "akan.component.write-chain",
|
|
92
|
+
key: "webkit/useBoard.tsx#useBoard",
|
|
93
|
+
file: "webkit/useBoard.tsx",
|
|
94
|
+
line: 9,
|
|
95
|
+
owner: "useBoard",
|
|
96
|
+
writes: ["st.do.removeBoard (per item)"],
|
|
97
|
+
writeCount: 2,
|
|
98
|
+
},
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
expect(message).toContain(
|
|
102
|
+
'akan.baseline.growth entry "lib/court/court.store.ts#assign" under akan.store.multi-write allows 3 writes, above the 2 at 3f2a9c1d0b7e',
|
|
103
|
+
);
|
|
104
|
+
expect(message).toContain(
|
|
105
|
+
"akan.write.placement sends 2 server write(s) from outside a store action or a module component: st.do.removeBoard, fetch.placeBoard",
|
|
106
|
+
);
|
|
107
|
+
expect(message).toContain(
|
|
108
|
+
"akan.service.call-in-loop service method syncAll calls once per item: this.pcService.getPc, more than the 0 recorded",
|
|
109
|
+
);
|
|
110
|
+
expect(message).toContain("hook useBoard sends a write per item from one handler: st.do.removeBoard (per item)");
|
|
111
|
+
expect(fixLinesOf(message).map((line) => line.split(":")[0])).toEqual([
|
|
112
|
+
" baseline entry",
|
|
113
|
+
" write sent outside a store action or module component",
|
|
114
|
+
" service call per item",
|
|
115
|
+
" handler with several writes",
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("keeps the server files' own fixes", () => {
|
|
120
|
+
const message = MaintainabilityReport.describe("apps/demo", [
|
|
121
|
+
fileOf("lib/court/Court.View.tsx"),
|
|
122
|
+
fileOf("lib/court/court.service.ts", 520),
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
expect(fixLinesOf(message)).toEqual([
|
|
126
|
+
" *.View.tsx: Move model-independent pieces into ui/ components and display logic onto Light<Model>.",
|
|
127
|
+
" *.service.ts: Move state transitions into document chain methods, and split an unrelated responsibility into its own module.",
|
|
128
|
+
]);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { AbstractDoc } from "./abstractDoc";
|
|
2
|
+
import type {
|
|
3
|
+
DebtRuleId,
|
|
4
|
+
MaintainabilityCommands,
|
|
5
|
+
MaintainabilityFailure,
|
|
6
|
+
MaintainabilityRuleId,
|
|
7
|
+
} from "./maintainabilityContract";
|
|
8
|
+
import { type FileKind, MaintainabilityLimits } from "./maintainabilityLimits";
|
|
9
|
+
|
|
10
|
+
type FixKey = FileKind | Exclude<MaintainabilityRuleId, "akan.size.file">;
|
|
11
|
+
|
|
12
|
+
export class MaintainabilityReport {
|
|
13
|
+
static readonly #clientSplitFix =
|
|
14
|
+
'Split it, in this order: a mode/kind table `{ [kind in cnst.X["value"]]: … }` in place of mode flags, each entry a component of the module owning the data of that kind; an interaction hook in `webkit/use<X>.tsx` with its pure logic in `common/` (`akan workflow plan create-interaction`); the writes behind one endpoint (`akan workflow plan add-mutation`); last, a sub-concept in its own module (`lib/<sub>` or `lib/_<feature>`), never model-bound code moved into `ui/`.';
|
|
15
|
+
static readonly #serverMarkupFix =
|
|
16
|
+
"Move model-independent pieces into ui/ components and display logic onto Light<Model>.";
|
|
17
|
+
static readonly #oneEndpointFix =
|
|
18
|
+
"Make it one service method that runs the writes inside `this.transaction(fn)`, with a side effect such as a push command in `this.afterCommit(fn)`, behind one endpoint the action calls once; a write per item becomes one endpoint taking the list. An MCP agent or another screen calling one endpoint alone skips the rest.";
|
|
19
|
+
static readonly #fixes: { [key in FixKey]: string } = {
|
|
20
|
+
".Template.tsx": MaintainabilityReport.#clientSplitFix,
|
|
21
|
+
".Unit.tsx": MaintainabilityReport.#serverMarkupFix,
|
|
22
|
+
".Util.tsx": MaintainabilityReport.#clientSplitFix,
|
|
23
|
+
".View.tsx": MaintainabilityReport.#serverMarkupFix,
|
|
24
|
+
".Zone.tsx": MaintainabilityReport.#clientSplitFix,
|
|
25
|
+
"ui/": MaintainabilityReport.#clientSplitFix,
|
|
26
|
+
"webkit/": MaintainabilityReport.#clientSplitFix,
|
|
27
|
+
".store.ts":
|
|
28
|
+
"Move business logic into the service behind an endpoint and derived values onto Light<Model>; an action is fetch, set, toast.",
|
|
29
|
+
".document.ts":
|
|
30
|
+
"Move display and predicate logic onto Light<Model>, and split an unrelated concept into its own module.",
|
|
31
|
+
".service.ts":
|
|
32
|
+
"Move state transitions into document chain methods, and split an unrelated responsibility into its own module.",
|
|
33
|
+
[AbstractDoc.suffix]:
|
|
34
|
+
"Trim the abstract to a title, one sentence naming what the module owns, and a `## Rules` list of the invariants the code cannot show.",
|
|
35
|
+
"akan.size.component": MaintainabilityReport.#clientSplitFix,
|
|
36
|
+
"akan.store.multi-write": MaintainabilityReport.#oneEndpointFix,
|
|
37
|
+
"akan.store.root-write-chain": `A store may call another through \`RootStore\`, but that does not make the writes one. ${MaintainabilityReport.#oneEndpointFix}`,
|
|
38
|
+
"akan.component.write-chain":
|
|
39
|
+
"Move the writes into one service method behind one endpoint, called by one store action the handler calls once; a component chaining `st.do` writes, or writing once per item, leaves an agent or another screen half-done.",
|
|
40
|
+
"akan.component.store-access": MaintainabilityReport.#clientSplitFix,
|
|
41
|
+
"akan.write.placement":
|
|
42
|
+
"A server write leaves the browser through one store action, started by one module component. Move a `fetch.<mutation>` into a store action, and hand `ui/` and `webkit/` code the write as an `onX` prop or hook argument from the module's `Zone` or `Util`: they stay free of any one model, and an agent or another screen reaches the write through the store.",
|
|
43
|
+
"akan.service.call-in-loop":
|
|
44
|
+
"Load the set once (`load<Model>Many(ids)`, one filter query) or write it once (one batch method), then loop in memory: a call per item is a round trip per item, so the endpoint slows as its input grows.",
|
|
45
|
+
"akan.baseline.growth":
|
|
46
|
+
"Restore the entry from the default branch, then split the file or move the writes instead: `akan lint` lowers an entry as the code shrinks and never raises one.",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
static readonly #labels: { [key in FixKey]?: string } = {
|
|
50
|
+
"ui/": "ui/**",
|
|
51
|
+
"webkit/": "webkit/**",
|
|
52
|
+
"akan.size.component": "component or hook",
|
|
53
|
+
"akan.store.multi-write": "store action with several writes",
|
|
54
|
+
"akan.store.root-write-chain": "store method chaining other stores' writes",
|
|
55
|
+
"akan.component.write-chain": "handler with several writes",
|
|
56
|
+
"akan.component.store-access": "component or hook reaching the store often",
|
|
57
|
+
"akan.write.placement": "write sent outside a store action or module component",
|
|
58
|
+
"akan.service.call-in-loop": "service call per item",
|
|
59
|
+
"akan.baseline.growth": "baseline entry",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
static readonly #units: { [rule in DebtRuleId]: string } = {
|
|
63
|
+
"akan.size.file": "lines",
|
|
64
|
+
"akan.size.component": "lines",
|
|
65
|
+
"akan.component.store-access": "store accesses",
|
|
66
|
+
"akan.store.multi-write": "writes",
|
|
67
|
+
"akan.store.root-write-chain": "writes",
|
|
68
|
+
"akan.component.write-chain": "writes",
|
|
69
|
+
"akan.write.placement": "writes",
|
|
70
|
+
"akan.service.call-in-loop": "calls per item",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
static describe(sysPath: string, failures: MaintainabilityFailure[], commands: MaintainabilityCommands = {}) {
|
|
74
|
+
const groups = new Map<string, { labels: string[]; rules: Set<MaintainabilityRuleId> }>();
|
|
75
|
+
for (const failure of failures) {
|
|
76
|
+
const fixKey = MaintainabilityReport.#fixKeyOf(failure);
|
|
77
|
+
const fix = MaintainabilityReport.#fixes[fixKey];
|
|
78
|
+
const group = groups.get(fix) ?? { labels: [], rules: new Set() };
|
|
79
|
+
const label = MaintainabilityReport.#labelOf(fixKey);
|
|
80
|
+
if (!group.labels.includes(label)) group.labels.push(label);
|
|
81
|
+
group.rules.add(failure.rule);
|
|
82
|
+
groups.set(fix, group);
|
|
83
|
+
}
|
|
84
|
+
return [
|
|
85
|
+
`[maintainability] ${failures.length} violation(s) in ${sysPath}:`,
|
|
86
|
+
...failures.map(
|
|
87
|
+
(failure) =>
|
|
88
|
+
` ${sysPath}/${failure.file}:${failure.line} ${failure.rule} ${MaintainabilityReport.#problemOf(failure)}`,
|
|
89
|
+
),
|
|
90
|
+
"How to fix:",
|
|
91
|
+
...[...groups].flatMap(([fix, { labels, rules }]) => [
|
|
92
|
+
` ${labels.join(", ")}: ${fix}`,
|
|
93
|
+
...[...new Set([...rules].flatMap((rule) => commands[rule] ?? []))].map((command) => ` Path: ${command}`),
|
|
94
|
+
]),
|
|
95
|
+
].join("\n");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
static entriesOf(sysPath: string, failures: MaintainabilityFailure[], commands: MaintainabilityCommands = {}) {
|
|
99
|
+
return failures.map((failure) => ({
|
|
100
|
+
file: `${sysPath}/${failure.file}`,
|
|
101
|
+
line: failure.line,
|
|
102
|
+
rule: failure.rule,
|
|
103
|
+
problem: MaintainabilityReport.#problemOf(failure),
|
|
104
|
+
fix: MaintainabilityReport.#fixes[MaintainabilityReport.#fixKeyOf(failure)],
|
|
105
|
+
path: commands[failure.rule]?.[0] ?? null,
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
static #problemOf(failure: MaintainabilityFailure) {
|
|
110
|
+
if (failure.kind === "baselineGrowth") {
|
|
111
|
+
const entry = `entry "${failure.key}" under ${failure.debtRule}`;
|
|
112
|
+
return failure.committed === undefined
|
|
113
|
+
? `${entry} is not in the baseline at ${failure.reference}, which may only shrink`
|
|
114
|
+
: `${entry} allows ${failure.count} ${MaintainabilityReport.#units[failure.debtRule]}, above the ${failure.committed} at ${failure.reference}; the baseline may only shrink`;
|
|
115
|
+
}
|
|
116
|
+
const grown = failure.recorded === undefined ? "" : `, more than the ${failure.recorded} recorded`;
|
|
117
|
+
if (failure.kind === "multiWrite") {
|
|
118
|
+
const writes = failure.writes.some((write) => write.endsWith("(per item)"))
|
|
119
|
+
? "a write per item"
|
|
120
|
+
: `${failure.writes.length} writes`;
|
|
121
|
+
const listed = `${failure.writes.join(", ")}${grown}`;
|
|
122
|
+
if (failure.rule === "akan.component.write-chain")
|
|
123
|
+
return `${MaintainabilityReport.#unitOf(failure.owner)} ${failure.owner} sends ${writes} from one handler: ${listed}`;
|
|
124
|
+
if (failure.rule === "akan.store.root-write-chain")
|
|
125
|
+
return `store method ${failure.owner} chains ${writes} through other stores from the browser: ${listed}`;
|
|
126
|
+
return `store action ${failure.owner} sends ${writes} from the browser: ${listed}`;
|
|
127
|
+
}
|
|
128
|
+
if (failure.kind === "writePlacement")
|
|
129
|
+
return `sends ${failure.writes.length} server write(s) from outside a store action or a module component: ${failure.writes.join(", ")}${grown}`;
|
|
130
|
+
if (failure.kind === "loopCall")
|
|
131
|
+
return `service method ${failure.owner} calls once per item: ${failure.calls.join(", ")}${grown}`;
|
|
132
|
+
if (failure.kind === "storeAccess")
|
|
133
|
+
return `${MaintainabilityReport.#unitOf(failure.component)} ${failure.component} reaches the store ${failure.accessCount} times, over the ${failure.maxAccesses}-access limit${failure.recorded === undefined ? "" : `, up from ${failure.recorded}`}`;
|
|
134
|
+
const subject = failure.component
|
|
135
|
+
? `${MaintainabilityReport.#unitOf(failure.component)} ${failure.component} has`
|
|
136
|
+
: "file has";
|
|
137
|
+
const up = failure.recorded === undefined ? "" : `, up from ${failure.recorded}`;
|
|
138
|
+
return `${subject} ${failure.lineCount} lines, over the ${failure.maxLines}-line limit${up}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
static #unitOf(name: string) {
|
|
142
|
+
return /^use[A-Z]/.test(name) ? "hook" : "component";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
static #fixKeyOf(failure: MaintainabilityFailure): FixKey {
|
|
146
|
+
if (failure.rule !== "akan.size.file") return failure.rule;
|
|
147
|
+
return MaintainabilityLimits.fileKindOf(failure.file) ?? "akan.size.component";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
static #labelOf(fixKey: FixKey) {
|
|
151
|
+
return MaintainabilityReport.#labels[fixKey] ?? `*${fixKey}`;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { createHash, generateKeyPairSync, sign } from "node:crypto";
|
|
3
3
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import * as nativeApi from "akanjs/native/api";
|
|
5
6
|
import { AkanAppConfig } from "../akanConfig";
|
|
6
7
|
import { tempDirs } from "../testHelpers";
|
|
7
8
|
import type { DesktopArch } from "./desktopBin";
|
|
@@ -13,12 +14,18 @@ const sha256 = (bytes: string | Uint8Array) => createHash("sha256").update(bytes
|
|
|
13
14
|
|
|
14
15
|
const keyOf = () => {
|
|
15
16
|
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
17
|
+
const raw = publicKey.export({ format: "der", type: "spki" }).subarray(12).toString("base64");
|
|
18
|
+
const signature = (bytes: string) => sign(null, Buffer.from(bytes), privateKey).toString("base64");
|
|
16
19
|
return {
|
|
17
|
-
publicKey:
|
|
18
|
-
|
|
20
|
+
publicKey: raw,
|
|
21
|
+
keyId: nativeApi.keyIdOf(raw),
|
|
22
|
+
sign: (bytes: string) => `${signature(bytes)}\n`,
|
|
23
|
+
seal: (bytes: string) =>
|
|
24
|
+
`${JSON.stringify({ manifest: Buffer.from(bytes).toString("base64"), keyId: nativeApi.keyIdOf(raw), signature: signature(bytes) }, null, 2)}\n`,
|
|
19
25
|
};
|
|
20
26
|
};
|
|
21
27
|
const key = keyOf();
|
|
28
|
+
const spare = keyOf();
|
|
22
29
|
const appId = "com.akancad.app";
|
|
23
30
|
|
|
24
31
|
interface Published {
|
|
@@ -35,12 +42,18 @@ const publish = async ({
|
|
|
35
42
|
channel = "main",
|
|
36
43
|
app = platform === "macos" ? "Akancad.app" : "akancad",
|
|
37
44
|
gz = "the release's gzip tar",
|
|
45
|
+
target = "default",
|
|
46
|
+
signer = key,
|
|
47
|
+
pair = "same",
|
|
38
48
|
}: {
|
|
39
49
|
platform?: DesktopPlatform;
|
|
40
50
|
arch?: DesktopArch;
|
|
41
51
|
channel?: string;
|
|
42
52
|
app?: string;
|
|
43
53
|
gz?: string;
|
|
54
|
+
target?: string;
|
|
55
|
+
signer?: ReturnType<typeof keyOf>;
|
|
56
|
+
pair?: "same" | "earlier" | "none";
|
|
44
57
|
} = {}) => {
|
|
45
58
|
const updatesDir = await makeDir();
|
|
46
59
|
const dir = path.join(updatesDir, `${platform}-${arch}`);
|
|
@@ -68,9 +81,17 @@ const publish = async ({
|
|
|
68
81
|
patches: [{ from, url: `app/${delta}`, sha256: sha256("a delta"), size: 7 }],
|
|
69
82
|
server: true,
|
|
70
83
|
};
|
|
71
|
-
|
|
72
|
-
await writeFile(
|
|
73
|
-
|
|
84
|
+
await mkdir(path.join(dir, channel), { recursive: true });
|
|
85
|
+
await writeFile(
|
|
86
|
+
path.join(dir, channel, "app.json"),
|
|
87
|
+
signer.seal(`${JSON.stringify({ ...manifest, schema: 2, target }, null, 2)}\n`),
|
|
88
|
+
);
|
|
89
|
+
if (pair !== "none") {
|
|
90
|
+
const earlier = { ...manifest, sequence: manifest.sequence - 100, bundle: "1.1.0-1761699900" };
|
|
91
|
+
const bytes = `${JSON.stringify(pair === "same" ? manifest : earlier, null, 2)}\n`;
|
|
92
|
+
await writeFile(path.join(dir, `${channel}.json`), bytes);
|
|
93
|
+
await writeFile(path.join(dir, `${channel}.json.sig`), key.sign(bytes));
|
|
94
|
+
}
|
|
74
95
|
return { updatesDir, dir, tarSha, delta, build } satisfies Published;
|
|
75
96
|
};
|
|
76
97
|
|
|
@@ -80,10 +101,30 @@ const read = (
|
|
|
80
101
|
platform = "macos",
|
|
81
102
|
arch = "arm64",
|
|
82
103
|
channel = "main",
|
|
83
|
-
|
|
104
|
+
publicKeys = [key.publicKey, spare.publicKey],
|
|
105
|
+
revokedKeys = [],
|
|
106
|
+
target = "default",
|
|
84
107
|
id = appId,
|
|
85
|
-
}: {
|
|
86
|
-
|
|
108
|
+
}: {
|
|
109
|
+
platform?: DesktopPlatform;
|
|
110
|
+
arch?: DesktopArch;
|
|
111
|
+
channel?: string;
|
|
112
|
+
publicKeys?: string[];
|
|
113
|
+
revokedKeys?: string[];
|
|
114
|
+
target?: string;
|
|
115
|
+
id?: string;
|
|
116
|
+
} = {},
|
|
117
|
+
) =>
|
|
118
|
+
DesktopRelease.read(updatesDir, {
|
|
119
|
+
platform,
|
|
120
|
+
arch,
|
|
121
|
+
channel,
|
|
122
|
+
appId: id,
|
|
123
|
+
target,
|
|
124
|
+
publicKeys,
|
|
125
|
+
revokedKeys,
|
|
126
|
+
api: nativeApi,
|
|
127
|
+
});
|
|
87
128
|
|
|
88
129
|
const entriesOf = (archive: string) =>
|
|
89
130
|
Bun.spawnSync(["tar", "-tf", archive]).stdout.toString().split(/\r?\n/).filter(Boolean);
|
|
@@ -122,7 +163,8 @@ describe("DesktopRelease", () => {
|
|
|
122
163
|
const published = await publish();
|
|
123
164
|
const { dir, tarSha, delta } = published;
|
|
124
165
|
await publish({ channel: "develop" }).then(async (other) => {
|
|
125
|
-
|
|
166
|
+
await mkdir(path.join(dir, "develop"), { recursive: true });
|
|
167
|
+
for (const name of ["develop.json", "develop.json.sig", "develop/app.json"])
|
|
126
168
|
await writeFile(path.join(dir, name), await readFile(path.join(other.dir, name)));
|
|
127
169
|
});
|
|
128
170
|
await writeFile(path.join(dir, "app", `${sha256("an earlier tar")}.tar.gz`), "an earlier release");
|
|
@@ -133,6 +175,7 @@ describe("DesktopRelease", () => {
|
|
|
133
175
|
|
|
134
176
|
expect(entries).toEqual([
|
|
135
177
|
"./release.json",
|
|
178
|
+
"./main/app.json",
|
|
136
179
|
"./main.json",
|
|
137
180
|
"./main.json.sig",
|
|
138
181
|
`./app/${tarSha}.tar.gz`,
|
|
@@ -152,24 +195,24 @@ describe("DesktopRelease", () => {
|
|
|
152
195
|
expect(headers.filter(({ prefix }) => prefix)).toEqual([]);
|
|
153
196
|
expect(headers.filter(({ type }) => type === "x").map(({ paxKeys }) => paxKeys)).toEqual([["path"]]);
|
|
154
197
|
const files = headers.filter(({ type }) => type === "0").map(({ name }) => name);
|
|
155
|
-
expect(files.slice(0,
|
|
156
|
-
expect(files.slice(
|
|
157
|
-
expect(files.slice(
|
|
198
|
+
expect(files.slice(0, 4)).toEqual(["./release.json", "./main/app.json", "./main.json", "./main.json.sig"]);
|
|
199
|
+
expect(files.slice(4).every((name) => name.startsWith("./app/"))).toBe(true);
|
|
200
|
+
expect(files.slice(4)).toEqual([`./app/${published.tarSha}.tar.gz`, `./app/${published.delta}`]);
|
|
158
201
|
expect(Buffer.byteLength(`./app/${published.delta}`)).toBeGreaterThan(100);
|
|
159
202
|
});
|
|
160
203
|
|
|
161
|
-
test("the
|
|
204
|
+
test("the envelope and the v1 pair go in byte for byte, beside a release.json of the CLI and the build time", async () => {
|
|
162
205
|
const published = await publish();
|
|
163
206
|
const release = await read(published);
|
|
164
207
|
const record = release.record({ teamId: "ABCDE12345" });
|
|
165
208
|
|
|
166
209
|
const out = await release.withArchive(record, extract);
|
|
167
210
|
|
|
168
|
-
for (const name of ["main.json", "main.json.sig"])
|
|
211
|
+
for (const name of ["main/app.json", "main.json", "main.json.sig"])
|
|
169
212
|
expect(sha256(await readFile(path.join(out, name)))).toBe(sha256(await readFile(path.join(published.dir, name))));
|
|
170
213
|
expect(await readFile(path.join(out, "release.json"), "utf8")).toBe(`${JSON.stringify(record, null, 2)}\n`);
|
|
171
214
|
expect(record).toEqual({
|
|
172
|
-
schema:
|
|
215
|
+
schema: 2,
|
|
173
216
|
cli: AkanAppConfig.akanVersion,
|
|
174
217
|
platform: "macos",
|
|
175
218
|
arch: "arm64",
|
|
@@ -192,22 +235,50 @@ describe("DesktopRelease", () => {
|
|
|
192
235
|
expect(await Bun.file(kept).exists()).toBe(false);
|
|
193
236
|
});
|
|
194
237
|
|
|
195
|
-
test("refuses
|
|
238
|
+
test("refuses an envelope no key of the ring signs, or a revoked one, before reading anything it names", async () => {
|
|
196
239
|
const published = await publish();
|
|
240
|
+
const envelope = path.join(published.dir, "main", "app.json");
|
|
241
|
+
const untrusted = `${envelope}: the update envelope is signed by key ${key.keyId}, which is not trusted; publish the release again.`;
|
|
197
242
|
|
|
198
|
-
await expect(read(published, {
|
|
199
|
-
|
|
243
|
+
await expect(read(published, { publicKeys: [keyOf().publicKey] })).rejects.toThrow(untrusted);
|
|
244
|
+
await expect(read(published, { revokedKeys: [key.keyId] })).rejects.toThrow(untrusted);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("takes a release a later key signed, and leaves out a v1 pair that is not that release", async () => {
|
|
248
|
+
const entriesFor = async (published: Published) => {
|
|
249
|
+
const release = await read(published);
|
|
250
|
+
return await release.withArchive(release.record(null), async (archive) => entriesOf(archive).slice(0, 4));
|
|
251
|
+
};
|
|
252
|
+
const tail = (published: Published) => `./app/${published.tarSha}.tar.gz`;
|
|
253
|
+
|
|
254
|
+
const bySpare = await publish({ signer: spare, pair: "earlier" });
|
|
255
|
+
expect(await entriesFor(bySpare)).toEqual([
|
|
256
|
+
"./release.json",
|
|
257
|
+
"./main/app.json",
|
|
258
|
+
tail(bySpare),
|
|
259
|
+
`./app/${bySpare.delta}`,
|
|
260
|
+
]);
|
|
261
|
+
const unpaired = await publish({ pair: "none" });
|
|
262
|
+
expect((await entriesFor(unpaired)).slice(0, 3)).toEqual(["./release.json", "./main/app.json", tail(unpaired)]);
|
|
263
|
+
const forged = await publish();
|
|
264
|
+
await writeFile(
|
|
265
|
+
path.join(forged.dir, "main.json.sig"),
|
|
266
|
+
spare.sign(await readFile(path.join(forged.dir, "main.json"), "utf8")),
|
|
200
267
|
);
|
|
268
|
+
expect((await entriesFor(forged)).slice(0, 3)).toEqual(["./release.json", "./main/app.json", tail(forged)]);
|
|
201
269
|
});
|
|
202
270
|
|
|
203
271
|
test("refuses a release of another app, and names a channel or CPU it holds none of", async () => {
|
|
204
272
|
const published = await publish();
|
|
205
273
|
|
|
206
274
|
await expect(read(published, { id: "com.other" })).rejects.toThrow(
|
|
207
|
-
"is not a release of com.other for macos-arm64 on channel main.",
|
|
275
|
+
"is not a release of com.other target default for macos-arm64 on channel main.",
|
|
276
|
+
);
|
|
277
|
+
await expect(read(published, { target: "pilot" })).rejects.toThrow(
|
|
278
|
+
`is not a release of ${appId} target pilot for macos-arm64 on channel main.`,
|
|
208
279
|
);
|
|
209
280
|
await expect(read(published, { channel: "develop" })).rejects.toThrow(
|
|
210
|
-
`${path.join(published.dir, "develop.json")} is missing: run \`akan publish-update\` first.`,
|
|
281
|
+
`${path.join(published.dir, "develop", "app.json")} is missing: run \`akan publish-update\` first.`,
|
|
211
282
|
);
|
|
212
283
|
await expect(read(published, { arch: "x64" })).rejects.toThrow("macos-x64");
|
|
213
284
|
});
|
|
@@ -219,9 +290,9 @@ describe("DesktopRelease", () => {
|
|
|
219
290
|
const send = () => release.withArchive(release.record(null), async () => "sent");
|
|
220
291
|
|
|
221
292
|
await writeFile(archive, "the release's gzip tar!");
|
|
222
|
-
await expect(send()).rejects.toThrow(`${archive} is not the file main.json names: publish the release again.`);
|
|
293
|
+
await expect(send()).rejects.toThrow(`${archive} is not the file main/app.json names: publish the release again.`);
|
|
223
294
|
await writeFile(archive, "the release's gzip taR");
|
|
224
|
-
await expect(send()).rejects.toThrow(`${archive} is not the file main.json names (its sha256 differs).`);
|
|
295
|
+
await expect(send()).rejects.toThrow(`${archive} is not the file main/app.json names (its sha256 differs).`);
|
|
225
296
|
});
|
|
226
297
|
});
|
|
227
298
|
|
|
@@ -310,7 +381,7 @@ describe("DesktopRelease signer", () => {
|
|
|
310
381
|
|
|
311
382
|
const windowsTool = (powershell: (out: string) => Promise<number>) =>
|
|
312
383
|
recordingTool(async (cmd, env) => {
|
|
313
|
-
if (cmd[0] === "tar.exe") {
|
|
384
|
+
if (cmd[0] === path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "tar.exe")) {
|
|
314
385
|
await Bun.write(path.join(extractedTo(cmd), cmd.at(-1) ?? ""), "MZ");
|
|
315
386
|
return { code: 0, output: "" };
|
|
316
387
|
}
|