@akanjs/devkit 3.1.0-rc.9 → 3.1.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 +382 -0
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/akanConfig/akanConfig.test.ts +8 -4
- package/akanConfig/akanConfig.ts +2 -1
- package/applicationBuildRunner.test.ts +77 -2
- package/applicationBuildRunner.ts +28 -5
- package/artifact/implicitRootLayout.test.ts +11 -1
- package/artifact/implicitRootLayout.ts +32 -7
- package/biome.base.json +8 -3
- package/biomeBase.test.ts +78 -0
- package/biomeBase.ts +1 -1
- package/codeAgent/agent/AkanCodeCompaction.ts +49 -0
- package/codeAgent/agent/AkanCodeServices.ts +2 -3
- package/codeAgent/agent/CodeAgent.ts +46 -2
- package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
- package/codeAgent/agent/akanCodeCompaction.test.ts +36 -0
- package/codeAgent/agent/codeAgent.test.ts +61 -3
- package/codeAgent/agent/codeAgentContract.test.ts +1 -0
- package/codeAgent/index.ts +1 -0
- package/codeAgent/skills/akan-code/SKILL.md +4 -0
- package/codeAgent/skills/akan-store/SKILL.md +4 -2
- package/codeAgent/tools/AkanCodePlugins.ts +3 -1
- package/codeAgent/tools/DeepSeekWeb.ts +127 -0
- package/codeAgent/tools/WebToolPack.ts +149 -25
- package/codeAgent/tools/akanCodePlugins.test.ts +191 -0
- package/codeAgent/tools/deepSeekWeb.test.ts +113 -0
- package/commandDecorators/argMeta.ts +1 -0
- package/commandDecorators/command.ts +49 -10
- package/commandDecorators/commandBuilder.ts +6 -3
- package/commandDecorators/commandDecorators.test.ts +169 -6
- package/commandDecorators/helpFormatter.ts +2 -1
- package/commandDecorators/targetMeta.ts +10 -0
- package/executors.test.ts +53 -1
- package/executors.ts +10 -6
- package/frontendBuild/csrArtifactBuilder.test.ts +40 -0
- package/frontendBuild/csrArtifactBuilder.ts +24 -1
- package/frontendBuild/cssCandidateCache.test.ts +16 -0
- package/frontendBuild/cssCandidateCache.ts +8 -6
- package/frontendBuild/fontPruner.ts +1 -0
- package/frontendBuild/mcpAppViewBuilder.test.ts +170 -0
- package/frontendBuild/mcpAppViewBuilder.ts +245 -0
- package/incrementalBuilder/buildBatch.proc.ts +3 -3
- package/incrementalBuilder/buildBatchProtocol.ts +2 -2
- package/lint/__fixtures__/no-conditional-st-hook/bad.tsx +45 -0
- package/lint/__fixtures__/no-conditional-st-hook/good.tsx +33 -0
- package/lint/no-conditional-st-hook.grit +146 -0
- package/mobile/desktopServerStage.ts +1 -0
- package/package.json +15 -15
- package/routeSourceValidator.test.ts +19 -4
- package/routeSourceValidator.ts +62 -14
- package/scanInfo.ts +36 -3
- package/subspace.test.ts +3 -2
- package/subspaceClone.ts +1 -12
- package/transforms/mcpAppPageTrimmer.test.ts +89 -0
- package/transforms/mcpAppPageTrimmer.ts +70 -0
- package/transforms/pureLazyMarker.test.ts +58 -0
- package/transforms/pureLazyMarker.ts +42 -0
- package/transforms/sideEffectImportOrder.test.ts +183 -0
- package/transforms/sideEffectImportOrder.ts +109 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export const A = () => {
|
|
2
|
+
const uploading = st.use.xUploading(); // @ok
|
|
3
|
+
const formUploading = st.use.xFormUploading(); // @ok
|
|
4
|
+
const busy = uploading || !!formUploading.voice; // @ok
|
|
5
|
+
const first = st.use.a() || fallback; // @ok
|
|
6
|
+
const picked = st.use.b() ? one : two; // @ok
|
|
7
|
+
if (st.use.c()) run(); // @ok
|
|
8
|
+
switch (st.use.mode()) { case "a": run(); } // @ok
|
|
9
|
+
for (const item of st.use.items()) run(item); // @ok
|
|
10
|
+
const selected = st.sel((s) => s.d); // @ok
|
|
11
|
+
const latest = st.ref((s) => s.e); // @ok
|
|
12
|
+
const save = st.tool("save").desc("d").exec(async () => { await st.do.saveX(); }); // @ok
|
|
13
|
+
const [f, setF] = st.useState("f", String).desc("d").init(""); // @ok
|
|
14
|
+
st.expose("g", String).desc("d").value(f); // @ok
|
|
15
|
+
useEffect(() => st.sub((s) => s.h, run), []); // @ok
|
|
16
|
+
return <div onClick={() => st.do.setNameOnX(st.get().name)}>{open ? <Child /> : null}</div>; // @ok
|
|
17
|
+
};
|
|
18
|
+
export const B = memo(() => { const a = st.use.a(); return null; }); // @ok
|
|
19
|
+
export const C = forwardRef((props, ref) => { const b = st.sel((s) => s.b); return null; }); // @ok
|
|
20
|
+
export const useTools = () => { const tool = (name) => st.tool(name).desc("d").exec(() => run()); tool("a"); }; // @ok
|
|
21
|
+
export const D = () => <Load.Units renderItem={(item) => <Row key={item.id} id={item.id} />} />; // @ok
|
|
22
|
+
export const E = () => { const busy = open && other.use.x(); return busy; }; // @ok
|
|
23
|
+
export const F = () => { const Submit = useMemo(() => () => { const v = st.use.x(); return v; }, []); return <Submit />; }; // @ok
|
|
24
|
+
export const G = () => { const Submit = useMemo(() => (open ? () => null : () => { const save = st.tool("s").desc("d").exec(run); return <Button onClick={save} />; }), [open]); return <Submit />; }; // @ok
|
|
25
|
+
export const H = () => { for (let i = st.use.start(); i < 3; i++) run(i); return null; }; // @ok
|
|
26
|
+
export const I = isNative ? () => { const a = st.use.a(); return a; } : () => null; // @ok
|
|
27
|
+
export const J = () => { const a = (() => st.use.x())(); return a; }; // @ok
|
|
28
|
+
export const K = () => { const Row = () => { const a = st.use.a(); return a; }; return <List row={Row} />; }; // @ok
|
|
29
|
+
const renderPage = () => { const a = st.use.a(); return a; }; // @ok
|
|
30
|
+
export const L = page().render(renderPage); // @ok
|
|
31
|
+
export const M = () => { const v = st.use.x(); props.onPick?.(v); return v; }; // @ok
|
|
32
|
+
export const N = () => { const a = st["use"].a(); const b = st.use["b"](); return a ?? b; }; // @ok
|
|
33
|
+
export const O = () => { let v = cached; v = st.use.a(); return v; }; // @ok
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// `st.use.<key>()`, `st.slice.<name>.use.<key>()`, `st.sel()`, `st.ref()` and the builders' last step
|
|
5
|
+
// (`st.tool(…)….exec()` / `.card()`, `st.useState(…)….init()`, `st.expose(…)….value()`) are React hooks that Biome's
|
|
6
|
+
// `useHookAtTopLevel` cannot see: it recognises a hook by a `use` prefix on the called name. A hook a render skips
|
|
7
|
+
// shifts every later hook by one slot, and production React does not check the order — a later `useRef` then
|
|
8
|
+
// receives a frozen store value and the screen dies far from the cause ("Cannot add property current, object is not
|
|
9
|
+
// extensible").
|
|
10
|
+
//
|
|
11
|
+
// Reported: the right operand of `||` / `&&` / `??` and of `||=` / `&&=` / `??=`, a ternary branch, an `if` / `else`
|
|
12
|
+
// body, a `switch` case, a loop (a `for` initializer runs once and is not one), a `catch` block, a default value,
|
|
13
|
+
// the arguments of an optional call, and a callback handed to a call or a JSX prop inside a component, inline or by
|
|
14
|
+
// a camelCase name (`.map`, `useEffect`, an event handler, a render prop — `Load.Units` calls `renderList` only while
|
|
15
|
+
// the list is non-empty). A test position is not reported: `if (st.use.x())` always runs. Each position counts up to
|
|
16
|
+
// the nearest function only, so a component defined in `useMemo` or picked by a module-level ternary is its own
|
|
17
|
+
// render; a PascalCase name is a component, which React renders rather than calls.
|
|
18
|
+
//
|
|
19
|
+
// Not reported: a hook after an early `return` (GritQL sees no statement order), a local helper the component
|
|
20
|
+
// calls (`const tool = (name) => st.tool(name)…exec(…)` runs a fixed number of times when called at top level),
|
|
21
|
+
// and a hook reached through anything but the `st` identifier.
|
|
22
|
+
//
|
|
23
|
+
pattern st_hook_call() {
|
|
24
|
+
or {
|
|
25
|
+
`st.use.$key($args)`,
|
|
26
|
+
`st.slice.$slice.use.$key($args)`,
|
|
27
|
+
`st.sel($args)`,
|
|
28
|
+
`st.ref($args)`,
|
|
29
|
+
`$chain.exec($args)` where { $chain <: r"st\s*\.\s*tool\s*\([\s\S]*" },
|
|
30
|
+
`$chain.card($args)` where { $chain <: r"st\s*\.\s*tool\s*\([\s\S]*" },
|
|
31
|
+
`$chain.init($args)` where { $chain <: r"st\s*\.\s*useState\s*\([\s\S]*" },
|
|
32
|
+
`$chain.value($args)` where { $chain <: r"st\s*\.\s*expose\s*\([\s\S]*" },
|
|
33
|
+
// Computed access (`st["use"].x()`, `st.use[key]()`) at any step, which would take a snippet per combination.
|
|
34
|
+
JsCallExpression(callee = $callee) where {
|
|
35
|
+
$callee <: or {
|
|
36
|
+
r"st\s*(?:\.\s*use|\[\s*[\x27\x22`]use[\x27\x22`]\s*\])\s*(?:\.\s*[\w$]+|\[[^\]]*\])",
|
|
37
|
+
r"st\s*(?:\.\s*slice|\[\s*[\x27\x22`]slice[\x27\x22`]\s*\])\s*(?:\.\s*[\w$]+|\[[^\]]*\])\s*(?:\.\s*use|\[\s*[\x27\x22`]use[\x27\x22`]\s*\])\s*(?:\.\s*[\w$]+|\[[^\]]*\])",
|
|
38
|
+
r"st\s*\[\s*[\x27\x22`](?:sel|ref)[\x27\x22`]\s*\]"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
pattern function_like() {
|
|
45
|
+
or {
|
|
46
|
+
JsArrowFunctionExpression(),
|
|
47
|
+
JsFunctionExpression(),
|
|
48
|
+
JsFunctionDeclaration(),
|
|
49
|
+
JsFunctionExportDefaultDeclaration(),
|
|
50
|
+
JsMethodObjectMember(),
|
|
51
|
+
JsGetterObjectMember(),
|
|
52
|
+
JsSetterObjectMember(),
|
|
53
|
+
JsMethodClassMember(),
|
|
54
|
+
JsGetterClassMember(),
|
|
55
|
+
JsSetterClassMember(),
|
|
56
|
+
JsConstructorClassMember()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// An IIFE runs where it stands, so the position around it still decides whether its hooks run.
|
|
61
|
+
pattern function_boundary() {
|
|
62
|
+
function_like() as $boundary where {
|
|
63
|
+
not $boundary <: within JsCallExpression(callee = $iifeCallee) where {
|
|
64
|
+
$iifeCallee <: or { $boundary, JsParenthesizedExpression(expression = $boundary) }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
pattern callback_slot() {
|
|
70
|
+
or { JsCallArguments(), JsxExpressionAttributeValue(), JsxExpressionChild() }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
pattern passed_by_name($name) {
|
|
74
|
+
or {
|
|
75
|
+
JsCallArguments(args = [..., $arg where { $arg <: $name }, ...]),
|
|
76
|
+
JsxExpressionAttributeValue(expression = $arg) where { $arg <: $name },
|
|
77
|
+
JsxExpressionChild(expression = $arg) where { $arg <: $name }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
JsCallExpression() as $call where {
|
|
82
|
+
$call <: st_hook_call(),
|
|
83
|
+
or {
|
|
84
|
+
$call <: within JsLogicalExpression(right = $right) where { $right <: contains $call } until function_boundary(),
|
|
85
|
+
// The operator token is not a field GritQL can read; `[^=]*` makes the first `=` in the text the operator's.
|
|
86
|
+
$call <: within JsAssignmentExpression(right = $right) as $assignment where {
|
|
87
|
+
$assignment <: r"[^=]*(?:\|\||&&|\?\?)=[\s\S]*",
|
|
88
|
+
$right <: contains $call
|
|
89
|
+
} until function_boundary(),
|
|
90
|
+
$call <: within JsConditionalExpression(consequent = $then, alternate = $else) where {
|
|
91
|
+
or { $then <: contains $call, $else <: contains $call }
|
|
92
|
+
} until function_boundary(),
|
|
93
|
+
$call <: within JsIfStatement(consequent = $then, else_clause = $else) where {
|
|
94
|
+
or { $then <: contains $call, $else <: contains $call }
|
|
95
|
+
} until function_boundary(),
|
|
96
|
+
$call <: within JsForStatement(initializer = $init) where { not $init <: contains $call } until function_boundary(),
|
|
97
|
+
$call <: within JsForOfStatement(body = $body) where { $body <: contains $call } until function_boundary(),
|
|
98
|
+
$call <: within JsForInStatement(body = $body) where { $body <: contains $call } until function_boundary(),
|
|
99
|
+
$call <: within or {
|
|
100
|
+
JsCaseClause(),
|
|
101
|
+
JsDefaultClause(),
|
|
102
|
+
JsWhileStatement(),
|
|
103
|
+
JsDoWhileStatement(),
|
|
104
|
+
JsCatchClause()
|
|
105
|
+
} until function_boundary(),
|
|
106
|
+
$call <: within or {
|
|
107
|
+
JsFormalParameter(initializer = $default),
|
|
108
|
+
JsObjectBindingPatternShorthandProperty(init = $default),
|
|
109
|
+
JsObjectBindingPatternProperty(init = $default),
|
|
110
|
+
JsArrayBindingPatternElement(init = $default),
|
|
111
|
+
JsObjectAssignmentPatternShorthandProperty(init = $default),
|
|
112
|
+
JsObjectAssignmentPatternProperty(init = $default),
|
|
113
|
+
JsArrayAssignmentPatternElement(init = $default)
|
|
114
|
+
} where { $default <: contains $call } until function_boundary(),
|
|
115
|
+
// A `?.` ahead of the callee's first `(` or `[` sits on its own chain, so a nullish link skips the arguments.
|
|
116
|
+
$call <: within JsCallExpression(callee = $callee, arguments = $arguments) as $outerCall where {
|
|
117
|
+
$arguments <: contains $call,
|
|
118
|
+
or { $outerCall <: `$optionalCallee?.($optionalArgs)`, $callee <: r"[^\[\(]*\?\.[\s\S]*" }
|
|
119
|
+
} until function_boundary(),
|
|
120
|
+
and {
|
|
121
|
+
$call <: within function_boundary() as $nearest until function_boundary(),
|
|
122
|
+
or {
|
|
123
|
+
// The callback must be handed over inside another function: a component passed to `memo()` /
|
|
124
|
+
// `forwardRef()` at module level is not one.
|
|
125
|
+
$nearest <: within callback_slot() as $slot where { $slot <: within function_boundary() } until function_boundary(),
|
|
126
|
+
and {
|
|
127
|
+
or {
|
|
128
|
+
$nearest <: JsFunctionDeclaration(id = $name),
|
|
129
|
+
$nearest <: within JsVariableDeclarator(id = $name, initializer = JsInitializerClause(expression = $value)) where {
|
|
130
|
+
$value <: $nearest
|
|
131
|
+
} until function_boundary()
|
|
132
|
+
},
|
|
133
|
+
$name <: r"[a-z_$][\w$]*",
|
|
134
|
+
$nearest <: within or { JsFunctionBody(), JsModule() } as $scope where {
|
|
135
|
+
$scope <: contains passed_by_name($name) as $handover where { $handover <: within function_boundary() }
|
|
136
|
+
} until or { JsFunctionBody(), JsModule() }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
register_diagnostic(
|
|
142
|
+
span = $call,
|
|
143
|
+
message = "This st hook does not run on every render — it sits behind a condition, in a loop, or in a nested function. React matches hooks by call order, so the hooks after it shift by one slot and a later one reads another hook's state; production React does not check, and the screen crashes somewhere else. Read each st.use.* into its own const at the top of the component, then combine the values: const uploading = st.use.xUploading(); const formUploading = st.use.xFormUploading(); const busy = uploading || !!formUploading.voice. For a list, move the hook into a child component rendered per item.",
|
|
144
|
+
severity = "error"
|
|
145
|
+
)
|
|
146
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.1.0
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"directory": "pkgs/@akanjs/devkit"
|
|
15
15
|
},
|
|
16
16
|
"engines": {
|
|
17
|
-
"bun": ">=1.4.
|
|
17
|
+
"bun": ">=1.4.4"
|
|
18
18
|
},
|
|
19
19
|
"exports": {
|
|
20
20
|
".": {
|
|
@@ -46,25 +46,25 @@
|
|
|
46
46
|
"@earendil-works/pi-coding-agent": "1.3.0",
|
|
47
47
|
"@earendil-works/pi-mcp": "1.3.0",
|
|
48
48
|
"@earendil-works/pi-tui": "1.3.0",
|
|
49
|
-
"@inquirer/prompts": "^8.
|
|
50
|
-
"@tailwindcss/node": "^4.3.
|
|
51
|
-
"akanjs": "3.1.0
|
|
52
|
-
"chalk": "^
|
|
53
|
-
"commander": "^
|
|
54
|
-
"dayjs": "^1.11.
|
|
55
|
-
"fontaine": "^0.
|
|
49
|
+
"@inquirer/prompts": "^8.7.3",
|
|
50
|
+
"@tailwindcss/node": "^4.3.3",
|
|
51
|
+
"akanjs": "3.1.0",
|
|
52
|
+
"chalk": "^6.0.1",
|
|
53
|
+
"commander": "^15.0.0",
|
|
54
|
+
"dayjs": "^1.11.23",
|
|
55
|
+
"fontaine": "^1.0.1",
|
|
56
56
|
"fonteditor-core": "^2.6.3",
|
|
57
|
-
"ignore": "^7.0.
|
|
58
|
-
"ora": "^9.4.
|
|
57
|
+
"ignore": "^7.0.12",
|
|
58
|
+
"ora": "^9.4.1",
|
|
59
59
|
"ssh2": "^1.17.0",
|
|
60
|
-
"subset-font": "^2.
|
|
60
|
+
"subset-font": "^2.9.0",
|
|
61
61
|
"tailwind-scrollbar": "^4.0.2",
|
|
62
|
-
"tailwindcss": "^4.3.
|
|
63
|
-
"typebox": "1.
|
|
62
|
+
"tailwindcss": "^4.3.3",
|
|
63
|
+
"typebox": "1.3.37",
|
|
64
64
|
"typescript": "^6.0.3"
|
|
65
65
|
},
|
|
66
66
|
"peerDependencies": {
|
|
67
|
-
"react": "19.3.
|
|
67
|
+
"react": "19.3.1"
|
|
68
68
|
},
|
|
69
69
|
"peerDependenciesMeta": {
|
|
70
70
|
"react": {
|
|
@@ -43,7 +43,7 @@ describe("RouteSourceValidator route chains", () => {
|
|
|
43
43
|
const chain = (source: string, kind: "page" | "layout", pattern: string) =>
|
|
44
44
|
RouteSourceValidator.validateRouteSourceExports(source, `page/${kind}.tsx`, kind, { pattern });
|
|
45
45
|
|
|
46
|
-
test("reads params, prompt and devOnly off a page() chain without evaluating it", () => {
|
|
46
|
+
test("reads params, prompt, apps and devOnly off a page() chain without evaluating it", () => {
|
|
47
47
|
const source = [
|
|
48
48
|
'import { page } from "akanjs/client";',
|
|
49
49
|
"export default page()",
|
|
@@ -51,11 +51,13 @@ describe("RouteSourceValidator route chains", () => {
|
|
|
51
51
|
' .search("status", String)',
|
|
52
52
|
" .config({ transition: 'stack', devOnly: true })",
|
|
53
53
|
' .prompt("briefProject", "Brief one project.")',
|
|
54
|
-
|
|
54
|
+
' .app("showProject", "Show one project.", ({ projectId }) => null)',
|
|
55
|
+
' .app("showBoard", "Show its board.", ({ projectId }) => null)',
|
|
56
|
+
" .render(async ({ projectId, showProject }) => showProject);",
|
|
55
57
|
].join("\n");
|
|
56
58
|
expect(chain(source, "page", "/:lang/project/:projectId")).toEqual({
|
|
57
59
|
devOnly: true,
|
|
58
|
-
chain: { kind: "page", params: ["projectId"], prompt: "briefProject" },
|
|
60
|
+
chain: { kind: "page", params: ["projectId"], prompt: "briefProject", apps: ["showProject", "showBoard"] },
|
|
59
61
|
});
|
|
60
62
|
});
|
|
61
63
|
|
|
@@ -88,16 +90,29 @@ describe("RouteSourceValidator route chains", () => {
|
|
|
88
90
|
expect(() => chain("export default page().param(name, ID).render(() => null);", "page", "/:lang/:x")).toThrow(
|
|
89
91
|
".param() takes a string literal first",
|
|
90
92
|
);
|
|
93
|
+
expect(() => chain('export default page().app(name, "x", () => null).render(() => null);', "page", "/")).toThrow(
|
|
94
|
+
".app() takes a string literal first",
|
|
95
|
+
);
|
|
91
96
|
expect(() => chain("export default page().config({ devOnly: flag }).render(() => null);", "page", "/")).toThrow(
|
|
92
97
|
"devOnly must be a literal true or false",
|
|
93
98
|
);
|
|
99
|
+
const indirect = [
|
|
100
|
+
'const memo = page().app("showMemo", "x", () => null).render(() => null);',
|
|
101
|
+
"export default memo;",
|
|
102
|
+
];
|
|
103
|
+
expect(() => chain(indirect.join("\n"), "page", "/")).toThrow('exports its chain through "memo"');
|
|
104
|
+
const renamed = [indirect[0], "export { memo as default };"];
|
|
105
|
+
expect(() => chain(renamed.join("\n"), "page", "/")).toThrow('exports its chain through "memo"');
|
|
94
106
|
});
|
|
95
107
|
|
|
96
|
-
test("still reads the legacy shape as before", () => {
|
|
108
|
+
test("still reads the legacy shape as before, which never takes a guard", () => {
|
|
97
109
|
const source = [
|
|
98
110
|
"export default function Page() { return null; }",
|
|
99
111
|
"export const pageConfig = { devOnly: false };",
|
|
100
112
|
].join("\n");
|
|
101
113
|
expect(chain(source, "page", "/")).toEqual({ devOnly: false });
|
|
114
|
+
expect(() => chain(`${source}\nexport const guard = async () => undefined;`, "page", "/")).toThrow(
|
|
115
|
+
"exports guard beside a legacy default",
|
|
116
|
+
);
|
|
102
117
|
});
|
|
103
118
|
});
|
package/routeSourceValidator.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface RouteSourceChain {
|
|
|
15
15
|
params: string[];
|
|
16
16
|
/** The `.prompt()` name, when the page publishes itself as an MCP prompt. */
|
|
17
17
|
prompt?: string;
|
|
18
|
+
/** The `.app()` names, each one MCP App the build gives a view of its own. */
|
|
19
|
+
apps: string[];
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
interface ChainStage {
|
|
@@ -80,6 +82,10 @@ export class RouteSourceValidator {
|
|
|
80
82
|
throw new Error(`[route-convention] head and generateHead cannot both be exported in ${filePath}`);
|
|
81
83
|
}
|
|
82
84
|
const chain = RouteSourceValidator.#readChain(sourceFile, filePath);
|
|
85
|
+
if (!chain && exported.has("guard"))
|
|
86
|
+
throw new Error(
|
|
87
|
+
`[route-convention] ${filePath} exports guard beside a legacy default; declare it with .guard() on its chain`,
|
|
88
|
+
);
|
|
83
89
|
if (!chain) return { devOnly: RouteSourceValidator.#readDevOnly(sourceFile, filePath) };
|
|
84
90
|
const named = [...exported].filter((name) => name !== "default");
|
|
85
91
|
if (named.length)
|
|
@@ -93,34 +99,75 @@ export class RouteSourceValidator {
|
|
|
93
99
|
if (options.pattern) RouteSourceValidator.#assertChainParams(chain, options.pattern, filePath);
|
|
94
100
|
return {
|
|
95
101
|
devOnly: chain.devOnly,
|
|
96
|
-
chain: {
|
|
102
|
+
chain: {
|
|
103
|
+
kind: chain.kind,
|
|
104
|
+
params: chain.params,
|
|
105
|
+
...(chain.prompt ? { prompt: chain.prompt } : {}),
|
|
106
|
+
apps: chain.apps,
|
|
107
|
+
},
|
|
97
108
|
};
|
|
98
109
|
}
|
|
99
110
|
|
|
100
111
|
// Null for the legacy shape. Read off the source because the build enumerates routes without importing them.
|
|
101
112
|
static #readChain(sourceFile: ts.SourceFile, filePath: string) {
|
|
102
113
|
for (const statement of sourceFile.statements) {
|
|
114
|
+
const local = RouteSourceValidator.#defaultLocalOf(statement);
|
|
115
|
+
if (local && RouteSourceValidator.#bindsChain(sourceFile, local))
|
|
116
|
+
throw new Error(
|
|
117
|
+
`[route-convention] ${filePath} exports its chain through "${local}"; write \`export default page()…\` — the build reads the chain without evaluating the module`,
|
|
118
|
+
);
|
|
103
119
|
if (!ts.isExportAssignment(statement) || statement.isExportEquals) continue;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
120
|
+
const walked = RouteSourceValidator.#walkChain(RouteSourceValidator.#unwrapExpression(statement.expression));
|
|
121
|
+
return walked ? RouteSourceValidator.#chainOf(walked.kind, walked.stages, filePath) : null;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
static #defaultLocalOf(statement: ts.Statement): string | undefined {
|
|
127
|
+
if (ts.isExportAssignment(statement) && !statement.isExportEquals) {
|
|
128
|
+
const node = RouteSourceValidator.#unwrapExpression(statement.expression);
|
|
129
|
+
return node && ts.isIdentifier(node) ? node.text : undefined;
|
|
130
|
+
}
|
|
131
|
+
if (!ts.isExportDeclaration(statement) || statement.moduleSpecifier || statement.isTypeOnly) return undefined;
|
|
132
|
+
const clause = statement.exportClause;
|
|
133
|
+
if (!clause || !ts.isNamedExports(clause)) return undefined;
|
|
134
|
+
const element = clause.elements.find((element) => !element.isTypeOnly && element.name.text === "default");
|
|
135
|
+
return element?.propertyName && ts.isIdentifier(element.propertyName) ? element.propertyName.text : undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
static #walkChain(start: ts.Expression | undefined) {
|
|
139
|
+
let node = start;
|
|
140
|
+
const stages: ChainStage[] = [];
|
|
141
|
+
while (node && ts.isCallExpression(node)) {
|
|
142
|
+
const callee = node.expression;
|
|
143
|
+
if (ts.isPropertyAccessExpression(callee)) {
|
|
144
|
+
stages.unshift({ name: callee.name.text, args: node.arguments });
|
|
145
|
+
node = callee.expression;
|
|
146
|
+
continue;
|
|
116
147
|
}
|
|
148
|
+
if (ts.isIdentifier(callee) && chainRoots.has(callee.text))
|
|
149
|
+
return { kind: callee.text as RouteSourceChain["kind"], stages };
|
|
117
150
|
return null;
|
|
118
151
|
}
|
|
119
152
|
return null;
|
|
120
153
|
}
|
|
121
154
|
|
|
155
|
+
static #bindsChain(sourceFile: ts.SourceFile, name: string) {
|
|
156
|
+
return sourceFile.statements.some(
|
|
157
|
+
(statement) =>
|
|
158
|
+
ts.isVariableStatement(statement) &&
|
|
159
|
+
statement.declarationList.declarations.some(
|
|
160
|
+
(declaration) =>
|
|
161
|
+
ts.isIdentifier(declaration.name) &&
|
|
162
|
+
declaration.name.text === name &&
|
|
163
|
+
!!RouteSourceValidator.#walkChain(RouteSourceValidator.#unwrapExpression(declaration.initializer)),
|
|
164
|
+
),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
122
168
|
static #chainOf(kind: RouteSourceChain["kind"], stages: ChainStage[], filePath: string) {
|
|
123
169
|
const params: string[] = [];
|
|
170
|
+
const apps: string[] = [];
|
|
124
171
|
let prompt: string | undefined;
|
|
125
172
|
let devOnly = false;
|
|
126
173
|
const literal = (stage: ChainStage) => {
|
|
@@ -134,10 +181,11 @@ export class RouteSourceValidator {
|
|
|
134
181
|
for (const stage of stages) {
|
|
135
182
|
if (stage.name === "param") params.push(literal(stage));
|
|
136
183
|
else if (stage.name === "prompt") prompt = literal(stage);
|
|
184
|
+
else if (stage.name === "app") apps.push(literal(stage));
|
|
137
185
|
else if (stage.name === "config")
|
|
138
186
|
devOnly = RouteSourceValidator.#devOnlyOf(RouteSourceValidator.#unwrapExpression(stage.args[0]), filePath);
|
|
139
187
|
}
|
|
140
|
-
return { kind, params, prompt, devOnly };
|
|
188
|
+
return { kind, params, prompt, apps, devOnly };
|
|
141
189
|
}
|
|
142
190
|
|
|
143
191
|
/** The same rule the loader applies: a page names every `[x]` it sits under, a layout may name a subset. */
|
package/scanInfo.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import type {
|
|
3
4
|
AppConfigResult,
|
|
@@ -295,11 +296,19 @@ class ScanInfo {
|
|
|
295
296
|
}
|
|
296
297
|
|
|
297
298
|
export const isAkanFrameworkDependency = (dep: string) => dep === "akanjs" || dep.startsWith("akanjs/");
|
|
299
|
+
export interface MigrationScanEntry {
|
|
300
|
+
id: string;
|
|
301
|
+
checksum: string;
|
|
302
|
+
}
|
|
303
|
+
|
|
298
304
|
export class AppInfo extends ScanInfo {
|
|
305
|
+
static readonly migrationDir = "script/migration";
|
|
306
|
+
static readonly #migrationIdRe = /^\d{12}_[A-Za-z][A-Za-z0-9]*$/;
|
|
299
307
|
readonly type = "app";
|
|
300
308
|
readonly exec: AppExecutor;
|
|
301
309
|
readonly akanConfig: AppConfigResult;
|
|
302
310
|
readonly libDeps: string[];
|
|
311
|
+
readonly migrations: MigrationScanEntry[];
|
|
303
312
|
|
|
304
313
|
static appInfos = new Map<string, AppInfo>();
|
|
305
314
|
static async fromExecutor(exec: AppExecutor, options: { refresh?: boolean } = {}) {
|
|
@@ -314,17 +323,21 @@ export class AppInfo extends ScanInfo {
|
|
|
314
323
|
LibInfo.libInfos.set(libName, await LibInfo.fromExecutor(libExecutor));
|
|
315
324
|
}),
|
|
316
325
|
);
|
|
317
|
-
const libDeps = await
|
|
318
|
-
|
|
326
|
+
const [libDeps, migrations] = await Promise.all([
|
|
327
|
+
AppInfo.#getAllLibDeps(exec, scanResult.libDeps),
|
|
328
|
+
AppInfo.scanMigrations(exec),
|
|
329
|
+
]);
|
|
330
|
+
const appInfo = new AppInfo(exec, scanResult as AppScanResult, libDeps, migrations);
|
|
319
331
|
AppInfo.appInfos.set(exec.name, appInfo);
|
|
320
332
|
return appInfo;
|
|
321
333
|
}
|
|
322
334
|
|
|
323
|
-
constructor(exec: AppExecutor, scanResult: AppScanResult, libDeps: string[]) {
|
|
335
|
+
constructor(exec: AppExecutor, scanResult: AppScanResult, libDeps: string[], migrations: MigrationScanEntry[] = []) {
|
|
324
336
|
super(scanResult);
|
|
325
337
|
this.exec = exec;
|
|
326
338
|
this.akanConfig = scanResult.akanConfig;
|
|
327
339
|
this.libDeps = libDeps;
|
|
340
|
+
this.migrations = migrations;
|
|
328
341
|
}
|
|
329
342
|
override getScanResult(): AppScanResult {
|
|
330
343
|
return this.scanResult as AppScanResult;
|
|
@@ -334,6 +347,26 @@ export class AppInfo extends ScanInfo {
|
|
|
334
347
|
(this.scanResult as AppScanResult).routes = routes;
|
|
335
348
|
}
|
|
336
349
|
|
|
350
|
+
//* Line endings and a BOM dropped: an editor or autocrlf would otherwise record another checksum for the same script.
|
|
351
|
+
static async scanMigrations(exec: AppExecutor) {
|
|
352
|
+
const files = (await exec.readdir(AppInfo.migrationDir)).filter(
|
|
353
|
+
(file) => file.endsWith(".ts") && !/\.(test|spec|d)\.ts$/.test(file),
|
|
354
|
+
);
|
|
355
|
+
return await Promise.all(
|
|
356
|
+
files.map(async (file) => {
|
|
357
|
+
const id = file.slice(0, -".ts".length);
|
|
358
|
+
if (!AppInfo.#migrationIdRe.test(id))
|
|
359
|
+
throw new Error(
|
|
360
|
+
`${AppInfo.migrationDir}/${file}: a migration is named <yyyyMMddHHmm>_<camelName>.ts; create one with \`akan create-migration\``,
|
|
361
|
+
);
|
|
362
|
+
const source = (await exec.readFile(`${AppInfo.migrationDir}/${file}`))
|
|
363
|
+
.replace(/^\uFEFF/, "")
|
|
364
|
+
.replaceAll("\r\n", "\n");
|
|
365
|
+
return { id, checksum: createHash("sha256").update(source).digest("hex") };
|
|
366
|
+
}),
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
337
370
|
static async #getAllLibDeps(exec: AppExecutor, libDeps: string[], libSet = new Set<string>()) {
|
|
338
371
|
await Promise.all(
|
|
339
372
|
libDeps.map(async (libName) => {
|
package/subspace.test.ts
CHANGED
|
@@ -245,7 +245,7 @@ describe("Subspace push", () => {
|
|
|
245
245
|
expect(second.sha).not.toBe(first.sha);
|
|
246
246
|
});
|
|
247
247
|
|
|
248
|
-
test("keeps the subspace's own root config and
|
|
248
|
+
test("keeps the subspace's own root config and a Jenkinsfile an earlier push left there, never the workspace's", async () => {
|
|
249
249
|
const { workRoot, bareRoot, subspace } = await makeMirror("own-served", "own-private", "own-kit");
|
|
250
250
|
const customer = await cloneSubspace(workRoot, bareRoot, "own-customer");
|
|
251
251
|
await write(path.join(customer, "akan.config.ts"), "export default { pipeline: {} };\n");
|
|
@@ -261,7 +261,8 @@ describe("Subspace push", () => {
|
|
|
261
261
|
expect(await FileSys.readText(path.join(subspaceRoot, "akan.config.ts"))).toContain(
|
|
262
262
|
"export default { pipeline: {} };",
|
|
263
263
|
);
|
|
264
|
-
expect(await FileSys.fileExists(path.join(subspaceRoot, "Jenkinsfile"))).toBe(
|
|
264
|
+
expect(await FileSys.fileExists(path.join(subspaceRoot, "Jenkinsfile"))).toBe(true);
|
|
265
|
+
expect(await FileSys.readText(path.join(subspaceRoot, "Jenkinsfile"))).toContain("pipeline { agent none }");
|
|
265
266
|
});
|
|
266
267
|
|
|
267
268
|
test("refuses a lock that resolves from a registry only this machine reaches, before it clones", async () => {
|
package/subspaceClone.ts
CHANGED
|
@@ -47,10 +47,6 @@ export class SubspaceClone {
|
|
|
47
47
|
this.#clone = new Executor(`subspace-${declaration.name}`, this.#clonePath);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
async #cloneGit(args: string[]) {
|
|
51
|
-
return await this.#workspace.spawn("git", ["-C", this.#clonePath, ...args]);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
50
|
async #ensure(branch: string) {
|
|
55
51
|
if (!(await FileSys.dirExists(path.join(this.#clonePath, ".git")))) {
|
|
56
52
|
await rm(this.#clonePath, { recursive: true, force: true });
|
|
@@ -160,15 +156,13 @@ export class SubspaceClone {
|
|
|
160
156
|
await rm(path.join(this.#clonePath, SubspaceClone.holdDir), { recursive: true, force: true });
|
|
161
157
|
}
|
|
162
158
|
|
|
163
|
-
//*
|
|
164
|
-
//* it goes. Any other is the subspace's own and is held through the extraction.
|
|
159
|
+
//* Held even when an earlier push left it, the workspace's Jenkinsfile among them: it is the subspace's only copy.
|
|
165
160
|
async #holdRootEntries() {
|
|
166
161
|
const holdRoot = path.join(this.#clonePath, SubspaceClone.holdDir, "root");
|
|
167
162
|
const held: string[] = [];
|
|
168
163
|
for (const entry of SubspaceClone.subspaceOwnedRootEntries) {
|
|
169
164
|
const from = path.join(this.#clonePath, entry);
|
|
170
165
|
if (!(await FileSys.entryExists(from))) continue;
|
|
171
|
-
if (await this.#isPushedCopy(entry)) continue;
|
|
172
166
|
await mkdir(holdRoot, { recursive: true });
|
|
173
167
|
await rename(from, path.join(holdRoot, entry));
|
|
174
168
|
held.push(entry);
|
|
@@ -185,11 +179,6 @@ export class SubspaceClone {
|
|
|
185
179
|
}
|
|
186
180
|
}
|
|
187
181
|
|
|
188
|
-
async #isPushedCopy(entry: string) {
|
|
189
|
-
const subject = await this.#cloneGit(["log", "-1", "--format=%s", "--", entry]).catch(() => "");
|
|
190
|
-
return subject.trim().startsWith(SubspaceClone.syncSubjectPrefix);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
182
|
static async #copyMissing(from: string, to: string) {
|
|
194
183
|
await mkdir(to, { recursive: true });
|
|
195
184
|
for (const entry of await readdir(from, { withFileTypes: true })) {
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { symlink } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { tempDirs, writeText } from "../testHelpers";
|
|
5
|
+
import { McpAppPageTrimmer } from "./mcpAppPageTrimmer";
|
|
6
|
+
|
|
7
|
+
const makeTempRoot = tempDirs("akan-mcp-app-trim-");
|
|
8
|
+
|
|
9
|
+
describe("McpAppPageTrimmer", () => {
|
|
10
|
+
test("hands every stage but the apps a function that draws nothing, and keeps the rest of the chain as written", () => {
|
|
11
|
+
const source = [
|
|
12
|
+
'import { fetch, Memo } from "@apps/minimal/client";',
|
|
13
|
+
'import { Screen } from "@apps/minimal/ui";',
|
|
14
|
+
"export default page()",
|
|
15
|
+
' .param("memoId", ID)',
|
|
16
|
+
" .config({ topInset: 48 })",
|
|
17
|
+
" .guard(async ({ memoId }) => { await fetch.memo(memoId); })",
|
|
18
|
+
" .head(<title>Memo</title>)",
|
|
19
|
+
" .loading(() => <Screen>loading</Screen>)",
|
|
20
|
+
' .app("showMemo", "Show one memo.", ({ memoId }) => <Memo.Zone.View view={fetch.viewMemo(memoId).memoView} />)',
|
|
21
|
+
" .render(({ memoId, showMemo }) => (",
|
|
22
|
+
' <Screen className="pb-10">{showMemo}</Screen>',
|
|
23
|
+
" ));",
|
|
24
|
+
].join("\n");
|
|
25
|
+
expect(McpAppPageTrimmer.trim(source, "memo.tsx")).toBe(
|
|
26
|
+
[
|
|
27
|
+
'import { fetch, Memo } from "@apps/minimal/client";',
|
|
28
|
+
'import { Screen } from "@apps/minimal/ui";',
|
|
29
|
+
"export default page()",
|
|
30
|
+
' .param("memoId", ID)',
|
|
31
|
+
" .config({ topInset: 48 })",
|
|
32
|
+
" .guard(() => null)",
|
|
33
|
+
" .head(() => null)",
|
|
34
|
+
" .loading(() => null)",
|
|
35
|
+
' .app("showMemo", "Show one memo.", ({ memoId }) => <Memo.Zone.View view={fetch.viewMemo(memoId).memoView} />)',
|
|
36
|
+
" .render(() => null);",
|
|
37
|
+
].join("\n"),
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("trims a page reached through a link, as a synced lib page is", async () => {
|
|
42
|
+
const root = await makeTempRoot();
|
|
43
|
+
await writeText(
|
|
44
|
+
path.join(root, "libs/shared/page/login.tsx"),
|
|
45
|
+
[
|
|
46
|
+
"const page = () => ({ render: (draw: () => string) => draw });",
|
|
47
|
+
'export default page().render(() => "drawn by the render");',
|
|
48
|
+
].join("\n"),
|
|
49
|
+
);
|
|
50
|
+
await symlink(path.join(root, "libs/shared/page"), path.join(root, "linked"));
|
|
51
|
+
await writeText(path.join(root, "entry.ts"), 'import login from "./linked/login.tsx";\nexport { login };\n');
|
|
52
|
+
const result = await Bun.build({
|
|
53
|
+
entrypoints: [path.join(root, "entry.ts")],
|
|
54
|
+
plugins: [McpAppPageTrimmer.plugin([path.join(root, "linked/login.tsx")])],
|
|
55
|
+
});
|
|
56
|
+
const [output] = result.outputs;
|
|
57
|
+
expect(result.success).toBe(true);
|
|
58
|
+
expect(await output?.text()).not.toContain("drawn by the render");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("drops a lazy component only the page's render drew, with what it imports", async () => {
|
|
62
|
+
const root = await makeTempRoot();
|
|
63
|
+
await writeText(path.join(root, "Globe.ts"), 'export default "a heavy globe";\n');
|
|
64
|
+
await writeText(
|
|
65
|
+
path.join(root, "trip.tsx"),
|
|
66
|
+
[
|
|
67
|
+
"const lazy = (load: () => Promise<unknown>) => ({ load });",
|
|
68
|
+
'const Globe = lazy(() => import("./Globe"));',
|
|
69
|
+
"const page = () => ({ app: (name: string, draw: () => unknown) => ({ render: (render: () => unknown) => [name, draw, render] }) });",
|
|
70
|
+
'export default page().app("showTrip", () => "a trip card").render(() => Globe);',
|
|
71
|
+
].join("\n"),
|
|
72
|
+
);
|
|
73
|
+
await writeText(path.join(root, "entry.ts"), 'import trip from "./trip.tsx";\nconsole.info(trip);\n');
|
|
74
|
+
const result = await Bun.build({
|
|
75
|
+
entrypoints: [path.join(root, "entry.ts")],
|
|
76
|
+
plugins: [McpAppPageTrimmer.plugin([path.join(root, "trip.tsx")])],
|
|
77
|
+
splitting: false,
|
|
78
|
+
});
|
|
79
|
+
const text = (await result.outputs[0]?.text()) ?? "";
|
|
80
|
+
expect(result.success).toBe(true);
|
|
81
|
+
expect(text).toContain("a trip card");
|
|
82
|
+
expect(text).not.toContain("a heavy globe");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("leaves a module with no chain as it is", () => {
|
|
86
|
+
const legacy = "export default function Page() { return <main />; }\nexport const pageConfig = {};";
|
|
87
|
+
expect(McpAppPageTrimmer.trim(legacy, "legacy.tsx")).toBe(legacy);
|
|
88
|
+
});
|
|
89
|
+
});
|