@akanjs/devkit 3.1.1-rc.9 → 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/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 +1 -1
- package/mobile/desktopRelease.ts +3 -1
- 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
|
+
}
|
|
@@ -381,7 +381,7 @@ describe("DesktopRelease signer", () => {
|
|
|
381
381
|
|
|
382
382
|
const windowsTool = (powershell: (out: string) => Promise<number>) =>
|
|
383
383
|
recordingTool(async (cmd, env) => {
|
|
384
|
-
if (cmd[0] === "tar.exe") {
|
|
384
|
+
if (cmd[0] === path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "tar.exe")) {
|
|
385
385
|
await Bun.write(path.join(extractedTo(cmd), cmd.at(-1) ?? ""), "MZ");
|
|
386
386
|
return { code: 0, output: "" };
|
|
387
387
|
}
|
package/mobile/desktopRelease.ts
CHANGED
|
@@ -245,7 +245,9 @@ Set-Content -LiteralPath $env:AKAN_SIGNER_OUT -Value $subject -Encoding UTF8`;
|
|
|
245
245
|
}
|
|
246
246
|
|
|
247
247
|
static async #subjectOf(run: SignatureTool, archive: string, tmp: string, exe: string) {
|
|
248
|
-
|
|
248
|
+
//? Windows' own bsdtar: a GNU tar ahead of it on PATH (Git's) takes `C:` for a remote host.
|
|
249
|
+
const tar = path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "tar.exe");
|
|
250
|
+
await run([tar, "-xzf", archive, "-C", tmp, exe]);
|
|
249
251
|
const extracted = path.join(tmp, exe);
|
|
250
252
|
if (!(await Bun.file(extracted).exists())) return { signer: null, unread: `the archive holds no ${exe}` };
|
|
251
253
|
const out = path.join(tmp, "signer.txt");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.1.1
|
|
3
|
+
"version": "3.1.1",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"./incrementalBuilder": "./incrementalBuilder/index.ts",
|
|
35
35
|
"./mobile": "./mobile/index.ts",
|
|
36
36
|
"./pipeline": "./pipeline/index.ts",
|
|
37
|
+
"./preview": "./preview/index.ts",
|
|
37
38
|
"./transforms": "./transforms/index.ts",
|
|
38
39
|
"./workflow": "./workflow/index.ts",
|
|
39
40
|
"./*.ts": "./*.ts",
|
|
@@ -48,7 +49,7 @@
|
|
|
48
49
|
"@earendil-works/pi-tui": "1.3.0",
|
|
49
50
|
"@inquirer/prompts": "^8.7.3",
|
|
50
51
|
"@tailwindcss/node": "^4.3.3",
|
|
51
|
-
"akanjs": "3.1.1
|
|
52
|
+
"akanjs": "3.1.1",
|
|
52
53
|
"chalk": "^6.0.1",
|
|
53
54
|
"commander": "^15.0.0",
|
|
54
55
|
"dayjs": "^1.11.23",
|
|
@@ -40,6 +40,9 @@ export class LinuxDockerTarget extends PlatformTestTarget {
|
|
|
40
40
|
"run",
|
|
41
41
|
"-d",
|
|
42
42
|
"--rm",
|
|
43
|
+
//? An init as PID 1 reaps orphans as a real host does; under `sleep` a killed orphan stays a zombie that still
|
|
44
|
+
//? answers kill(pid, 0).
|
|
45
|
+
"--init",
|
|
43
46
|
"--name",
|
|
44
47
|
container,
|
|
45
48
|
"--cpus",
|
|
@@ -2,6 +2,8 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
|
+
export type PreviewEngine = "webkit" | "chrome";
|
|
6
|
+
|
|
5
7
|
// Bun.WebView's `webkit` backend is the macOS system framework and throws on every other platform.
|
|
6
8
|
export class PreviewChrome {
|
|
7
9
|
/** Read by `Bun.WebView` itself; listed so the availability check agrees with what the launcher will do. */
|
|
@@ -30,7 +32,7 @@ export class PreviewChrome {
|
|
|
30
32
|
"Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
31
33
|
];
|
|
32
34
|
|
|
33
|
-
static get backend():
|
|
35
|
+
static get backend(): PreviewEngine {
|
|
34
36
|
const declared = process.env[PreviewChrome.backendEnvKey];
|
|
35
37
|
if (declared === "webkit" || declared === "chrome") return declared;
|
|
36
38
|
return process.platform === "darwin" ? "webkit" : "chrome";
|
|
@@ -53,11 +55,15 @@ export class PreviewChrome {
|
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
static get available() {
|
|
56
|
-
return PreviewChrome.
|
|
58
|
+
return PreviewChrome.isAvailable();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
static isAvailable(engine: PreviewEngine = PreviewChrome.backend) {
|
|
62
|
+
return engine === "webkit" ? process.platform === "darwin" : !!PreviewChrome.executable();
|
|
57
63
|
}
|
|
58
64
|
|
|
59
|
-
static backendOption() {
|
|
60
|
-
if (
|
|
65
|
+
static backendOption(engine: PreviewEngine = PreviewChrome.backend) {
|
|
66
|
+
if (engine === "webkit") return "webkit";
|
|
61
67
|
// Docker's default 64MB /dev/shm is below what Chromium's renderer allocates; the crash looks like a hung load.
|
|
62
68
|
const argv = ["--disable-dev-shm-usage"];
|
|
63
69
|
// Chromium refuses uid 0 with its sandbox on, and uid 0 is the ordinary uid in a container.
|
|
@@ -70,9 +76,9 @@ export class PreviewChrome {
|
|
|
70
76
|
"apt-get update && apt-get install -y --no-install-recommends chromium fonts-liberation fonts-noto-color-emoji fonts-noto-cjk && rm -rf /var/lib/apt/lists/*",
|
|
71
77
|
];
|
|
72
78
|
|
|
73
|
-
static unavailableReason() {
|
|
74
|
-
if (PreviewChrome.
|
|
75
|
-
if (
|
|
79
|
+
static unavailableReason(engine: PreviewEngine = PreviewChrome.backend) {
|
|
80
|
+
if (PreviewChrome.isAvailable(engine)) return undefined;
|
|
81
|
+
if (engine === "webkit")
|
|
76
82
|
return `The webkit WebView backend exists only on macOS. Set ${PreviewChrome.backendEnvKey}=chrome and install a browser.`;
|
|
77
83
|
return [
|
|
78
84
|
"The page preview needs a browser and this machine has none on PATH.",
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { PreviewChrome, type PreviewEngine } from "./PreviewChrome";
|
|
2
|
+
|
|
3
|
+
export interface PreviewProbeOptions {
|
|
4
|
+
screenshot?: boolean;
|
|
5
|
+
width?: number;
|
|
6
|
+
height?: number;
|
|
7
|
+
engine?: PreviewEngine;
|
|
8
|
+
/** Bounds the whole probe: a dev server that is recovering takes the connection and never answers. */
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
/** Waited after the load, so the errors effects throw land before the page is read. */
|
|
11
|
+
settleMs?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PreviewProbeResult {
|
|
15
|
+
url: string;
|
|
16
|
+
finalUrl: string;
|
|
17
|
+
status: number | null;
|
|
18
|
+
title: string;
|
|
19
|
+
text: string;
|
|
20
|
+
consoleErrors: string[];
|
|
21
|
+
consoleWarnings: string[];
|
|
22
|
+
pageErrors: string[];
|
|
23
|
+
/** Cheap structural smells that read as "the page is broken" without anyone looking at it. */
|
|
24
|
+
domFindings: string[];
|
|
25
|
+
screenshot?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface WebViewLike {
|
|
29
|
+
navigate(url: string): Promise<void>;
|
|
30
|
+
evaluate(expression: string): Promise<unknown>;
|
|
31
|
+
screenshot(options: { encoding: "base64" }): Promise<string>;
|
|
32
|
+
close(): void | Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface PageRead {
|
|
36
|
+
href: string;
|
|
37
|
+
title: string;
|
|
38
|
+
text: string;
|
|
39
|
+
status: number | null;
|
|
40
|
+
empty: boolean;
|
|
41
|
+
pageErrors: string[];
|
|
42
|
+
domFindings: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Bun.WebView rather than a downloaded browser: on macOS its webkit backend needs no Chromium at all.
|
|
46
|
+
// It is experimental, so its whole surface stays behind this adapter; a Bun minor that changes it breaks one file.
|
|
47
|
+
export class PreviewView {
|
|
48
|
+
static get available() {
|
|
49
|
+
return PreviewView.isAvailable();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
static isAvailable(engine: PreviewEngine = PreviewChrome.backend) {
|
|
53
|
+
return typeof (Bun as unknown as { WebView?: unknown }).WebView === "function" && PreviewChrome.isAvailable(engine);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static unavailableReason(engine: PreviewEngine = PreviewChrome.backend) {
|
|
57
|
+
if (typeof (Bun as unknown as { WebView?: unknown }).WebView !== "function")
|
|
58
|
+
return "This Bun build has no WebView, so the page preview is off.";
|
|
59
|
+
return PreviewChrome.unavailableReason(engine);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
static async probe(
|
|
63
|
+
url: string,
|
|
64
|
+
{
|
|
65
|
+
screenshot = false,
|
|
66
|
+
width = 1280,
|
|
67
|
+
height = 800,
|
|
68
|
+
engine = PreviewChrome.backend,
|
|
69
|
+
timeoutMs = 30_000,
|
|
70
|
+
settleMs = 0,
|
|
71
|
+
}: PreviewProbeOptions = {},
|
|
72
|
+
): Promise<PreviewProbeResult> {
|
|
73
|
+
const logs: string[] = [];
|
|
74
|
+
const deadline = Date.now() + timeoutMs;
|
|
75
|
+
const within = <T>(work: Promise<T>, what: string) => PreviewView.#within(work, deadline, `${what} ${url}`);
|
|
76
|
+
const view = PreviewView.#open({ width, height, engine, onConsole: (line) => logs.push(line) });
|
|
77
|
+
try {
|
|
78
|
+
let page = await PreviewView.#load(view, url, settleMs, within);
|
|
79
|
+
//? Right after a save the first load can render nothing (`[rscClient] module not loaded`); the next one has it.
|
|
80
|
+
if (page.empty) {
|
|
81
|
+
logs.length = 0;
|
|
82
|
+
page = await PreviewView.#load(view, url, settleMs, within);
|
|
83
|
+
}
|
|
84
|
+
const result: PreviewProbeResult = {
|
|
85
|
+
url,
|
|
86
|
+
finalUrl: page.href,
|
|
87
|
+
status: page.status ?? (await PreviewView.#statusOf(page.href, deadline)),
|
|
88
|
+
title: page.title,
|
|
89
|
+
text: page.text,
|
|
90
|
+
consoleErrors: logs.filter((line) => line.startsWith("error")),
|
|
91
|
+
consoleWarnings: logs.filter((line) => line.startsWith("warn")),
|
|
92
|
+
pageErrors: page.pageErrors,
|
|
93
|
+
domFindings: page.domFindings,
|
|
94
|
+
};
|
|
95
|
+
if (screenshot) result.screenshot = await within(view.screenshot({ encoding: "base64" }), "screenshotting");
|
|
96
|
+
return result;
|
|
97
|
+
} finally {
|
|
98
|
+
await view.close();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
static async #load(
|
|
103
|
+
view: WebViewLike,
|
|
104
|
+
url: string,
|
|
105
|
+
settleMs: number,
|
|
106
|
+
within: <T>(work: Promise<T>, what: string) => Promise<T>,
|
|
107
|
+
): Promise<PageRead> {
|
|
108
|
+
await within(view.navigate(url), "loading");
|
|
109
|
+
if (settleMs > 0) await Bun.sleep(settleMs);
|
|
110
|
+
const read = (await within(view.evaluate(readPageExpression), "reading")) as Partial<PageRead> | null;
|
|
111
|
+
return {
|
|
112
|
+
href: typeof read?.href === "string" ? read.href : url,
|
|
113
|
+
title: typeof read?.title === "string" ? read.title : "",
|
|
114
|
+
text: typeof read?.text === "string" ? read.text : "",
|
|
115
|
+
status: typeof read?.status === "number" && read.status > 0 ? read.status : null,
|
|
116
|
+
empty: read?.empty === true,
|
|
117
|
+
pageErrors: PreviewView.#strings(read?.pageErrors),
|
|
118
|
+
domFindings: PreviewView.#strings(read?.domFindings),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
//? webkit has no Navigation Timing `responseStatus`, so the status comes from asking the server again, unredirected.
|
|
123
|
+
static async #statusOf(href: string, deadline: number) {
|
|
124
|
+
const left = Math.max(1, deadline - Date.now());
|
|
125
|
+
const response = await fetch(href, { redirect: "manual", signal: AbortSignal.timeout(left) }).catch(() => null);
|
|
126
|
+
return response?.status ?? null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
static async #within<T>(work: Promise<T>, deadline: number, what: string): Promise<T> {
|
|
130
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
131
|
+
const expired = new Promise<never>((_, reject) => {
|
|
132
|
+
timer = setTimeout(
|
|
133
|
+
() => reject(new Error(`Timed out ${what}; the dev server may be rebuilding or stuck.`)),
|
|
134
|
+
Math.max(0, deadline - Date.now()),
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
try {
|
|
138
|
+
return await Promise.race([work, expired]);
|
|
139
|
+
} finally {
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
static #open({
|
|
145
|
+
width,
|
|
146
|
+
height,
|
|
147
|
+
engine,
|
|
148
|
+
onConsole,
|
|
149
|
+
}: {
|
|
150
|
+
width: number;
|
|
151
|
+
height: number;
|
|
152
|
+
engine: PreviewEngine;
|
|
153
|
+
onConsole: (line: string) => void;
|
|
154
|
+
}) {
|
|
155
|
+
const Ctor = (Bun as unknown as { WebView: new (options: unknown) => WebViewLike }).WebView;
|
|
156
|
+
return new Ctor({
|
|
157
|
+
width,
|
|
158
|
+
height,
|
|
159
|
+
dataStore: "ephemeral",
|
|
160
|
+
backend: PreviewChrome.backendOption(engine),
|
|
161
|
+
// The console option is a callback taking `(type, ...args)`, not an object of console methods.
|
|
162
|
+
console: (type: string, ...args: unknown[]) => onConsole(`${type} ${PreviewView.formatConsole(args)}`),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Substitutes `%s` as the console does, since React passes a warning's detail as arguments, and cuts it short. */
|
|
167
|
+
static formatConsole(args: unknown[]) {
|
|
168
|
+
const [first, ...rest] = args;
|
|
169
|
+
const head =
|
|
170
|
+
typeof first === "string"
|
|
171
|
+
? first.replace(/%[sdifoOc]/g, (token) => {
|
|
172
|
+
if (!rest.length) return token;
|
|
173
|
+
const value = rest.shift();
|
|
174
|
+
return token === "%c" ? "" : String(value);
|
|
175
|
+
})
|
|
176
|
+
: String(first);
|
|
177
|
+
return [head, ...rest.map(String)]
|
|
178
|
+
.join(" ")
|
|
179
|
+
.replace(/\n(\s*\n)+/g, "\n")
|
|
180
|
+
.slice(0, 1_500);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
static #strings(value: unknown) {
|
|
184
|
+
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// evaluate() takes one expression (a statement list is a SyntaxError), hence the IIFE; one call in flight per view.
|
|
189
|
+
// `view.title` is only refreshed by a navigation, so the title is read from the document.
|
|
190
|
+
const readPageExpression = `(() => {
|
|
191
|
+
const pageErrors = [];
|
|
192
|
+
const domFindings = [];
|
|
193
|
+
const body = document.body;
|
|
194
|
+
const rendered = body ? Array.from(body.children).filter((el) => !["SCRIPT", "STYLE", "TEMPLATE", "LINK", "NOSCRIPT"].includes(el.tagName) && !el.classList.contains("__akan_hmr_overlay")) : [];
|
|
195
|
+
const empty = !rendered.length && !(body && body.innerText && body.innerText.trim());
|
|
196
|
+
if (empty) pageErrors.push("The page body rendered empty.");
|
|
197
|
+
if (self.__AKAN_HMR_SYSTEM_PAGE__) pageErrors.push("The server could not render this page and answered with its error page: " + (body ? body.innerText.trim().replace(/\\s+/g, " ").slice(0, 400) : ""));
|
|
198
|
+
const overlay = document.querySelector('.__akan_hmr_overlay[data-status="error"]');
|
|
199
|
+
if (overlay) pageErrors.push("The dev build failed: " + overlay.textContent.trim().slice(0, 400));
|
|
200
|
+
const all = Array.from(document.querySelectorAll("body *"));
|
|
201
|
+
const viewportWidth = document.documentElement.clientWidth;
|
|
202
|
+
const pageWidth = document.documentElement.scrollWidth;
|
|
203
|
+
if (pageWidth > viewportWidth + 8) {
|
|
204
|
+
const wide = all.find((el) => el.getBoundingClientRect().right > viewportWidth + 8);
|
|
205
|
+
domFindings.push("The page is " + pageWidth + "px wide in a " + viewportWidth + "px viewport" + (wide ? ", first wider element: " + (wide.tagName.toLowerCase() + "." + (typeof wide.className === "string" ? wide.className : "")).slice(0, 120) : "") + ".");
|
|
206
|
+
}
|
|
207
|
+
const invisibleText = all.filter((el) => {
|
|
208
|
+
if (!el.textContent || !el.textContent.trim()) return false;
|
|
209
|
+
const style = getComputedStyle(el);
|
|
210
|
+
return style.color === style.backgroundColor && style.backgroundColor !== "rgba(0, 0, 0, 0)";
|
|
211
|
+
});
|
|
212
|
+
if (invisibleText.length) domFindings.push(invisibleText.length + " element(s) render text in the same colour as their background.");
|
|
213
|
+
const unresolved = all.filter((el) => /(^|\\s)(bg|text|border)-(base-100|base-content|primary-content|error)(\\s|$)/.test(typeof el.className === "string" ? el.className : ""));
|
|
214
|
+
if (unresolved.length) domFindings.push(unresolved.length + " element(s) use a dropped daisyUI colour slot that renders no CSS.");
|
|
215
|
+
const navigation = performance.getEntriesByType("navigation")[0];
|
|
216
|
+
const status = navigation && typeof navigation.responseStatus === "number" ? navigation.responseStatus : null;
|
|
217
|
+
const text = body ? body.innerText.trim().replace(/\\s+/g, " ").slice(0, 600) : "";
|
|
218
|
+
return { href: location.href, title: document.title, text, status, empty, pageErrors, domFindings };
|
|
219
|
+
})()`;
|
package/preview/index.ts
ADDED