@akanjs/devkit 3.0.0-alpha.11 → 3.0.0-alpha.111
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 +15 -0
- package/DEV_RUNTIME_KNOBS.md +34 -0
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/agentsIndex.test.ts +10 -0
- package/agentsIndex.ts +21 -1
- package/aiEditor.ts +1 -1
- package/akanApp/BackendImportGraph.test.ts +120 -0
- package/akanApp/BackendImportGraph.ts +167 -0
- package/akanApp/akanApp.host.test.ts +16 -678
- package/akanApp/akanApp.host.ts +113 -578
- package/akanApp/devHostPolicy.test.ts +542 -0
- package/akanApp/devHostPolicy.ts +442 -0
- package/akanApp/index.ts +2 -0
- package/akanConfig/akanConfig.test.ts +182 -14
- package/akanConfig/akanConfig.ts +143 -47
- package/akanConfig/types.ts +9 -0
- package/akanContext.ts +30 -14
- package/akanMcpContract.ts +25 -4
- package/appSelectionMemory.ts +28 -0
- package/applicationBuildRunner.test.ts +37 -2
- package/applicationBuildRunner.ts +52 -23
- package/artifact/implicitRootLayout.test.ts +70 -1
- package/artifact/implicitRootLayout.ts +41 -14
- package/artifact/routeSeedIndex.test.ts +1 -0
- package/biome.base.json +28 -1
- package/biomeBase.ts +1 -1
- package/capacitorApp.ts +10 -0
- package/cloud/globalConfig.ts +9 -2
- package/codegenLock.test.ts +137 -0
- package/codegenLock.ts +136 -0
- package/commandDecorators/argMeta.ts +9 -1
- package/commandDecorators/command.ts +94 -7
- package/commandDecorators/commandBuilder.ts +20 -5
- package/commandDecorators/commandDecorators.test.ts +115 -3
- package/commandDecorators/helpFormatter.ts +4 -0
- package/dependencyScanner.test.ts +99 -0
- package/dependencyScanner.ts +27 -24
- package/devkitUtils.test.ts +0 -104
- package/executors.test.ts +135 -7
- package/executors.ts +115 -25
- package/fileEditor.ts +19 -19
- package/fileSys.ts +19 -1
- package/formSetterScanner.test.ts +80 -0
- package/formSetterScanner.ts +92 -0
- package/frontendBuild/autoImportSync.test.ts +58 -0
- package/frontendBuild/autoImportSync.ts +7 -2
- package/frontendBuild/buildRouteClient.test.ts +47 -24
- package/frontendBuild/clientBuildTypes.ts +4 -0
- package/frontendBuild/clientEntriesBundler.ts +4 -1
- package/frontendBuild/clientEntryDiscovery.ts +2 -2
- package/frontendBuild/csrArtifactBuilder.ts +116 -84
- package/frontendBuild/cssCompiler.ts +63 -10
- package/frontendBuild/devGeneratedIndexSync.ts +3 -2
- package/frontendBuild/fontOptimizer.test.ts +75 -2
- package/frontendBuild/fontOptimizer.ts +98 -27
- package/frontendBuild/fontPruner.test.ts +220 -0
- package/frontendBuild/fontPruner.ts +206 -0
- package/frontendBuild/frontendBuild.test.ts +124 -10
- package/frontendBuild/hmrWatcher.ts +1 -1
- package/frontendBuild/index.ts +1 -1
- package/frontendBuild/pagesBundleBuilder.ts +3 -3
- package/frontendBuild/pagesEntrySourceGenerator.ts +11 -88
- package/frontendBuild/routeClientBuilder.ts +12 -5
- package/frontendBuild/sourceMtimeIndex.ts +1 -1
- package/frontendBuild/ssrBaseArtifactBuilder.ts +21 -4
- package/frontendBuild/styleContract.ts +14 -20
- package/frontendBuild/themeValidator.ts +22 -17
- package/frontendBuild/vendorSpecifiers.ts +1 -0
- package/frontendBuild/watchRootResolver.test.ts +129 -0
- package/frontendBuild/watchRootResolver.ts +66 -1
- package/incrementalBuilder/devWatchBatch.test.ts +18 -20
- package/incrementalBuilder/devWatchBatch.ts +6 -2
- package/incrementalBuilder/incrementalBuilder.host.ts +51 -7
- package/incrementalBuilder/incrementalBuilder.proc.ts +17 -6
- package/index.ts +0 -7
- package/integration/devStabilityHarness.ts +13 -19
- package/libSource.test.ts +112 -0
- package/libSource.ts +126 -0
- package/lint/__fixtures__/README.md +40 -0
- package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
- package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
- package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
- package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
- package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
- package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
- package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
- package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
- package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
- package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
- package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
- package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
- package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
- package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
- package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
- package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
- package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
- package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
- package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
- package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
- package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
- package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
- package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
- package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
- package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
- package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
- package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
- package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
- package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
- package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
- package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
- package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
- package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
- package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
- package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
- package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
- package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
- package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
- package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
- package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
- package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
- package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
- package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
- package/lint/gritRules.test.ts +178 -0
- package/lint/no-arbitrary-color.grit +1 -1
- package/lint/no-async-component-in-ui.grit +35 -0
- package/lint/no-bang-comment-in-client.grit +23 -10
- package/lint/no-daisyui-legacy-class.grit +26 -9
- package/lint/no-deprecated-log-level.grit +17 -0
- package/lint/no-init-fetch-in-client.grit +47 -0
- package/lint/no-inline-color.grit +10 -8
- package/lint/no-interpolated-arbitrary-class.grit +3 -3
- package/lint/no-model-type-in-util-zone.grit +58 -0
- package/lint/no-raw-palette-class.grit +3 -3
- package/lint/no-unpublished-form-setter.grit +41 -0
- package/lint/non-scalar-props-restricted.grit +16 -7
- package/linter.test.ts +80 -0
- package/linter.ts +82 -13
- package/package.json +5 -8
- package/prompter.ts +9 -4
- package/qualityScanner.test.ts +80 -142
- package/qualityScanner.ts +89 -31
- package/recipeScanner.ts +4 -1
- package/routeSourceValidator.test.ts +63 -0
- package/routeSourceValidator.ts +123 -16
- package/scanInfo.ts +32 -26
- package/semver.test.ts +26 -0
- package/semver.ts +31 -0
- package/slicePlanner.test.ts +182 -0
- package/slicePlanner.ts +235 -0
- package/ssrScanner.test.ts +301 -0
- package/subspace.test.ts +418 -0
- package/subspace.ts +723 -0
- package/subspaceConfig.ts +76 -0
- package/transforms/asyncDefaultExportDetector.ts +105 -0
- package/transforms/barrelImportsPlugin.ts +1 -1
- package/transforms/externalizeFrameworkPlugin.ts +0 -1
- package/transforms/transforms.test.ts +5 -5
- package/transforms/tsconfigPackageResolver.test.ts +230 -0
- package/tsconfig.json +6 -2
- package/typeChecker.ts +1 -1
- package/types.ts +1 -0
- package/uploadRelease.ts +2 -2
- package/workflow/executor.test.ts +146 -0
- package/workspaceLayout.test.ts +56 -4
- package/workspaceLayout.ts +48 -2
- package/builder.ts +0 -164
- package/extractDeps.ts +0 -86
- package/frontendBuild/styleGuard.test.ts +0 -165
- package/frontendBuild/styleGuard.ts +0 -322
- package/getCredentials.ts +0 -19
- package/getModelFileData.ts +0 -62
- package/mcpScanner.ts +0 -217
- package/src/capacitorApp.ts +0 -282
- package/storeScanner.ts +0 -173
- package/streamAi.ts +0 -45
- package/ui/MultiScrollList.tsx +0 -242
- package/ui/ScrollList.tsx +0 -107
- package/ui/index.ts +0 -2
- package/useStdoutDimensions.ts +0 -20
package/qualityScanner.test.ts
CHANGED
|
@@ -193,185 +193,123 @@ const signalOf = (entries: string) =>
|
|
|
193
193
|
"",
|
|
194
194
|
].join("\n");
|
|
195
195
|
|
|
196
|
-
describe("AkanQualityScanner
|
|
197
|
-
test("flags an
|
|
196
|
+
describe("AkanQualityScanner layout rules", () => {
|
|
197
|
+
test("flags an unknown app root file but not a facet entrypoint", async () => {
|
|
198
198
|
const root = await makeWorkspace({
|
|
199
|
-
"
|
|
200
|
-
|
|
201
|
-
`export class PostEndpoint extends endpoint(srv.post, ({ query }) => ({`,
|
|
202
|
-
` publishPost: query(Boolean, { guards: [Admin], mcp: { expose: true } }).exec(() => true),`,
|
|
203
|
-
` archivePost: query(Boolean, { guards: [Admin], mcp: { expose: true } }).exec(() => true),`,
|
|
204
|
-
` quietPost: query(Boolean, { guards: [Admin] }).exec(() => true),`,
|
|
205
|
-
`})) {}`,
|
|
206
|
-
"",
|
|
207
|
-
].join("\n"),
|
|
208
|
-
"libs/shared/lib/post/post.dictionary.ts": [
|
|
209
|
-
`export const dictionary = modelDictionary(["en", "ko"]).endpoint((fn) => ({`,
|
|
210
|
-
` publishPost: fn(["Publish", "게시"]).desc(["Publishes a post", "글을 게시합니다"]),`,
|
|
211
|
-
` archivePost: fn(["Archive", "보관"]).arg((t) => ({`,
|
|
212
|
-
` postId: t(["Post", "글"]).desc(["Post to archive", "보관할 글"]),`,
|
|
213
|
-
` })),`,
|
|
214
|
-
`}));`,
|
|
215
|
-
"",
|
|
216
|
-
].join("\n"),
|
|
199
|
+
"apps/demo/client.ts": "export const client = 1;\n",
|
|
200
|
+
"apps/demo/helper.ts": "export const helper = 1;\n",
|
|
217
201
|
});
|
|
218
202
|
|
|
219
|
-
const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.
|
|
203
|
+
const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.layout.app-root-file");
|
|
220
204
|
|
|
221
|
-
// `archivePost` describes only its argument, which says nothing about when to reach for the tool.
|
|
222
205
|
expect(warnings).toHaveLength(1);
|
|
223
|
-
expect(warnings[0]?.
|
|
224
|
-
expect(warnings[0]?.line).toBe(4);
|
|
225
|
-
expect(warnings[0]?.fix).toContain(".desc(");
|
|
206
|
+
expect(warnings[0]?.file).toBe("apps/demo/helper.ts");
|
|
226
207
|
});
|
|
227
208
|
|
|
228
|
-
test("
|
|
209
|
+
test("flags an unknown lib root file but not a facet entrypoint", async () => {
|
|
229
210
|
const root = await makeWorkspace({
|
|
230
|
-
"libs/
|
|
231
|
-
|
|
232
|
-
` inPublic: init({ mcp: { expose: true } }).exec(function () { return this.postService.queryInPublic(); }),`,
|
|
233
|
-
` inTag: init({ mcp: { expose: true } }).exec(function () { return this.postService.queryInTag(); }),`,
|
|
234
|
-
` inDraft: init({ mcp: { expose: true } }).exec(function () { return this.postService.queryInDraft(); }),`,
|
|
235
|
-
].join("\n"),
|
|
236
|
-
),
|
|
237
|
-
"libs/shared/lib/post/post.dictionary.ts": [
|
|
238
|
-
`export const dictionary = modelDictionary(["en", "ko"])`,
|
|
239
|
-
` .slice((fn) => ({`,
|
|
240
|
-
` inPublic: fn(["In Public", "공개"]).desc(["Public posts", "공개된 글"]),`,
|
|
241
|
-
` inTag: fn(["In Tag", "태그"]),`,
|
|
242
|
-
` inDraft: fn(["In Draft", "초안"]),`,
|
|
243
|
-
` }))`,
|
|
244
|
-
` .endpoint((fn) => ({`,
|
|
245
|
-
` postListInTag: fn(["Post List In Tag", "태그별 글"]).desc(["Posts under a tag", "태그에 속한 글"]),`,
|
|
246
|
-
` }));`,
|
|
247
|
-
"",
|
|
248
|
-
].join("\n"),
|
|
211
|
+
"libs/demo/client.ts": "export const client = 1;\n",
|
|
212
|
+
"libs/demo/helper.ts": "export const helper = 1;\n",
|
|
249
213
|
});
|
|
250
214
|
|
|
251
|
-
const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.
|
|
215
|
+
const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.layout.lib-root-file");
|
|
252
216
|
|
|
253
217
|
expect(warnings).toHaveLength(1);
|
|
254
|
-
expect(warnings[0]?.
|
|
218
|
+
expect(warnings[0]?.file).toBe("libs/demo/helper.ts");
|
|
255
219
|
});
|
|
256
220
|
|
|
257
|
-
test("flags
|
|
221
|
+
test("flags a root folder no facet owns, on both sides", async () => {
|
|
258
222
|
const root = await makeWorkspace({
|
|
259
|
-
"
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
` inDraft: init({ ...sharedOption, mcp: { expose: true } }).exec(function () { return this.postService.queryInDraft(); }),`,
|
|
264
|
-
].join("\n"),
|
|
265
|
-
),
|
|
266
|
-
"libs/shared/lib/post/post.dictionary.ts": `export const dictionary = modelDictionary(["en", "ko"]);\n`,
|
|
223
|
+
"apps/demo/base/helper.ts": "export const helper = 1;\n",
|
|
224
|
+
"apps/demo/common/helper.ts": "export const helper = 1;\n",
|
|
225
|
+
"libs/demo/base/helper.ts": "export const helper = 1;\n",
|
|
226
|
+
"libs/demo/common/helper.ts": "export const helper = 1;\n",
|
|
267
227
|
});
|
|
268
228
|
|
|
269
|
-
const
|
|
229
|
+
const result = await new AkanQualityScanner().scan(root);
|
|
270
230
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
expect(
|
|
231
|
+
expect(rulesOf(result, "akan.layout.app-root-folder").map((warning) => warning.file)).toEqual([
|
|
232
|
+
"apps/demo/base/helper.ts",
|
|
233
|
+
]);
|
|
234
|
+
expect(rulesOf(result, "akan.layout.lib-root-folder").map((warning) => warning.file)).toEqual([
|
|
235
|
+
"libs/demo/base/helper.ts",
|
|
236
|
+
]);
|
|
275
237
|
});
|
|
276
238
|
|
|
277
|
-
test("
|
|
239
|
+
test("keeps a root signal test out of the lib facet rule", async () => {
|
|
278
240
|
const root = await makeWorkspace({
|
|
279
|
-
"libs/
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
"libs/shared/lib/post/post.dictionary.ts": `export const dictionary = modelDictionary(["en", "ko"]);\n`,
|
|
241
|
+
"libs/demo/lib/cnst.ts": "export const cnst = 1;\n",
|
|
242
|
+
"libs/demo/lib/user.signal.test.ts": "export const test = 1;\n",
|
|
243
|
+
"libs/demo/lib/helper.ts": "export const helper = 1;\n",
|
|
283
244
|
});
|
|
284
245
|
|
|
285
|
-
|
|
246
|
+
const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.layout.lib-facet-file");
|
|
247
|
+
|
|
248
|
+
expect(warnings).toHaveLength(1);
|
|
249
|
+
expect(warnings[0]?.file).toBe("libs/demo/lib/helper.ts");
|
|
286
250
|
});
|
|
287
251
|
});
|
|
288
252
|
|
|
289
|
-
describe("
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
body,
|
|
295
|
-
`}`,
|
|
296
|
-
"",
|
|
297
|
-
].join("\n");
|
|
298
|
-
|
|
299
|
-
test("flags only the actions whose endpoint description would be the wrong one", async () => {
|
|
300
|
-
const root = await makeWorkspace({
|
|
301
|
-
"libs/shared/lib/post/post.store.ts": storeOf(
|
|
302
|
-
[
|
|
303
|
-
// Named after the endpoint it calls, so it already reads as that endpoint's `.desc()`.
|
|
304
|
-
` async publishPost(id: string) { await fetch.publishPost(id); }`,
|
|
305
|
-
// Renamed: the store name is the verb a user would say, the endpoint name is the verb the API has.
|
|
306
|
-
` async archive(id: string) { await fetch.archivePost(id); }`,
|
|
307
|
-
// Two endpoints behind one action, so neither one's description covers it.
|
|
308
|
-
` async publishAndTag(id: string) { await fetch.publishPost(id); await fetch.tagPost(id); }`,
|
|
309
|
-
// Never leaves the client, so it is not published and its description would be read by nobody.
|
|
310
|
-
` toggleDraft() { this.set({ draft: !this.get().draft }); }`,
|
|
311
|
-
].join("\n"),
|
|
312
|
-
),
|
|
313
|
-
"libs/shared/lib/post/post.dictionary.ts": `export const dictionary = modelDictionary(["en", "ko"]);\n`,
|
|
314
|
-
});
|
|
253
|
+
describe("bang comments in browser-reachable files", () => {
|
|
254
|
+
const bangWarningsIn = async (files: Record<string, string>) => {
|
|
255
|
+
const result = await new AkanQualityScanner().scan(await makeWorkspace(files));
|
|
256
|
+
return rulesOf(result, "akan.file.bang-comment-in-client");
|
|
257
|
+
};
|
|
315
258
|
|
|
316
|
-
|
|
259
|
+
test("flags the first and last line, which the grit rule cannot reach", async () => {
|
|
260
|
+
const warnings = await bangWarningsIn({
|
|
261
|
+
"apps/demo/ui/First.tsx": "//! FIXME: broken\nexport const First = () => null;\n",
|
|
262
|
+
"apps/demo/ui/Last.tsx": "export const Last = () => null;\n//! left behind\n",
|
|
263
|
+
});
|
|
317
264
|
|
|
318
|
-
expect(warnings.map((warning) => warning.
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
expect(warnings[0]?.fix).toContain("
|
|
265
|
+
expect(warnings.map((warning) => `${warning.file}:${warning.line}`).sort()).toEqual([
|
|
266
|
+
"apps/demo/ui/First.tsx:1",
|
|
267
|
+
"apps/demo/ui/Last.tsx:2",
|
|
268
|
+
]);
|
|
269
|
+
expect(warnings[0]?.fix).toContain("// FIXME:");
|
|
323
270
|
});
|
|
324
271
|
|
|
325
|
-
test("
|
|
326
|
-
const
|
|
327
|
-
"
|
|
328
|
-
|
|
329
|
-
` async archive(id: string) { await fetch.archivePost(id); }`,
|
|
330
|
-
` async logout() { await fetch.signoutUser(); }`,
|
|
331
|
-
` async retire(id: string) { await fetch.archivePost(id); }`,
|
|
332
|
-
].join("\n"),
|
|
272
|
+
test("flags the block form and a marker trailing code", async () => {
|
|
273
|
+
const warnings = await bangWarningsIn({
|
|
274
|
+
"apps/demo/lib/task/Task.Zone.tsx": ["/*! keep me */", "export const Zone = () => null; //! and me", ""].join(
|
|
275
|
+
"\n",
|
|
333
276
|
),
|
|
334
|
-
"libs/shared/lib/post/post.dictionary.ts": [
|
|
335
|
-
`export const dictionary = modelDictionary(["en", "ko"])`,
|
|
336
|
-
` .endpoint((fn) => ({`,
|
|
337
|
-
// Not the endpoint it calls — an entry under the action's own name describes the action.
|
|
338
|
-
` logout: fn(["Log Out", "로그아웃"]).desc(["Ends the session", "세션을 종료합니다"]),`,
|
|
339
|
-
` }))`,
|
|
340
|
-
` .store((t) => ({`,
|
|
341
|
-
` archive: t(["Archive", "보관"]).desc(["Files the post away", "글을 보관합니다"]),`,
|
|
342
|
-
` retire: t(["Retire", "폐기"]),`,
|
|
343
|
-
` }));`,
|
|
344
|
-
"",
|
|
345
|
-
].join("\n"),
|
|
346
277
|
});
|
|
347
278
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
// `retire` has an entry but no `.desc()`, which is a label and not a sentence an agent can act on.
|
|
351
|
-
expect(warnings).toHaveLength(1);
|
|
352
|
-
expect(warnings[0]?.message).toContain("retire");
|
|
279
|
+
expect(warnings.map((warning) => warning.line)).toEqual([1, 2]);
|
|
353
280
|
});
|
|
354
281
|
|
|
355
|
-
test("
|
|
356
|
-
|
|
357
|
-
"
|
|
358
|
-
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
expect(rulesOf(await new AkanQualityScanner().scan(root), "akan.agent.missing-store-description")).toHaveLength(0);
|
|
282
|
+
test("ignores a double slash inside a string literal", async () => {
|
|
283
|
+
expect(
|
|
284
|
+
await bangWarningsIn({ "apps/demo/ui/Url.tsx": 'export const url = "https://host//!path";\n' }),
|
|
285
|
+
).toHaveLength(0);
|
|
362
286
|
});
|
|
363
|
-
});
|
|
364
287
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
288
|
+
test("stays out of server files and tests", async () => {
|
|
289
|
+
const body = "//! server-only marker\nexport const value = 1;\n";
|
|
290
|
+
expect(
|
|
291
|
+
await bangWarningsIn({
|
|
292
|
+
"apps/demo/srvkit/storage.ts": body,
|
|
293
|
+
"apps/demo/lib/task/task.service.ts": body,
|
|
294
|
+
"apps/demo/ui/Card.test.tsx": body,
|
|
295
|
+
"apps/demo/lib/task/task.signal.spec.ts": body,
|
|
296
|
+
}),
|
|
297
|
+
).toHaveLength(0);
|
|
298
|
+
});
|
|
373
299
|
|
|
374
|
-
|
|
375
|
-
|
|
300
|
+
test("covers every path role the grit rule is scoped to", async () => {
|
|
301
|
+
const body = "//! marker\nexport const value = 1;\n";
|
|
302
|
+
const files = [
|
|
303
|
+
"apps/demo/ui/Card.tsx",
|
|
304
|
+
"apps/demo/webkit/useThing.tsx",
|
|
305
|
+
"apps/demo/common/format.ts",
|
|
306
|
+
"apps/demo/page/task/_index.tsx",
|
|
307
|
+
"apps/demo/lib/task/task.constant.ts",
|
|
308
|
+
"apps/demo/lib/task/task.store.ts",
|
|
309
|
+
"apps/demo/lib/task/Task.Unit.tsx",
|
|
310
|
+
];
|
|
311
|
+
const warnings = await bangWarningsIn(Object.fromEntries(files.map((file) => [file, body])));
|
|
312
|
+
|
|
313
|
+
expect(warnings.map((warning) => warning.file).sort()).toEqual([...files].sort());
|
|
376
314
|
});
|
|
377
315
|
});
|
package/qualityScanner.ts
CHANGED
|
@@ -5,13 +5,12 @@ import { RESERVED_ROUTE_CONFIG_EXPORTS } from "akanjs/common";
|
|
|
5
5
|
import ignore from "ignore";
|
|
6
6
|
import ts from "typescript";
|
|
7
7
|
import { AbstractDoc } from "./abstractDoc";
|
|
8
|
-
import {
|
|
8
|
+
import { FormSetterScanner } from "./formSetterScanner";
|
|
9
9
|
import { formatSsrBalance, type SsrBalanceEntry, SsrScanner } from "./ssrScanner";
|
|
10
|
-
import {
|
|
11
|
-
import { appRootAllowedFiles, libFacetRootAllowedFiles } from "./workspaceLayout";
|
|
10
|
+
import { isAllowedLibFacetRootFile, rootAllowedDirs, rootAllowedFiles } from "./workspaceLayout";
|
|
12
11
|
|
|
13
12
|
type QualitySeverity = "warning";
|
|
14
|
-
type QualityScope = "global" | "file" | "convention" | "layout" | "ssr" | "
|
|
13
|
+
type QualityScope = "global" | "file" | "convention" | "layout" | "ssr" | "agent";
|
|
15
14
|
|
|
16
15
|
export interface QualityWarning {
|
|
17
16
|
rule: string;
|
|
@@ -90,7 +89,8 @@ const SUGGESTED_RULES = [
|
|
|
90
89
|
"Keep apps/*/lib and libs/*/lib root files limited to generated support facets such as cnst.ts, db.ts, dict.ts, sig.ts, srv.ts, st.ts, useClient.ts, and useServer.ts.",
|
|
91
90
|
"Use domain module folders consistently: lib/<model> for database modules, lib/_<service> for service modules, and lib/__scalar/<scalar> for scalar modules.",
|
|
92
91
|
"Keep module UI filenames predictable: database modules use <Model>.Template/Unit/Util/View/Zone.tsx, service modules use <Service>.Util/Zone.tsx, and scalar modules use <Scalar>.Template/Unit.tsx.",
|
|
93
|
-
"
|
|
92
|
+
"Hand a form control its store setter by reference so the field publishes an agent tool; put normalization in the control's transform prop and a multi-write in a _postSet<Field> hook on the store.",
|
|
93
|
+
"Move shared app and lib utilities to common/ instead of creating apps/*/base or libs/*/base.",
|
|
94
94
|
"Avoid large mixed-purpose class files; class export files should import helpers from neighboring utility files instead of declaring them inline.",
|
|
95
95
|
];
|
|
96
96
|
|
|
@@ -117,6 +117,8 @@ const RULE_FIXES: Record<string, string> = {
|
|
|
117
117
|
"Run `akan compact <app-or-lib>` to rewrite the abstract with the AI editor, keeping only the invariants and workflows the source files cannot show.",
|
|
118
118
|
"akan.file.placeholder-export":
|
|
119
119
|
"Remove the placeholder export; generated indexes should only re-export real modules.",
|
|
120
|
+
"akan.file.bang-comment-in-client":
|
|
121
|
+
"Rewrite the marker as `// FIXME:` or `// TODO:`. Keep the bang form for server, srvkit, and CLI files.",
|
|
120
122
|
"akan.file.dictionary-stale-text": "Replace the scaffold text with real localized copy for this dictionary entry.",
|
|
121
123
|
"akan.file.global-declaration":
|
|
122
124
|
"Move the global declaration into an approved low-level integration file (e.g. webkit) and keep it isolated.",
|
|
@@ -124,14 +126,22 @@ const RULE_FIXES: Record<string, string> = {
|
|
|
124
126
|
"Move the Window augmentation into an approved browser integration file and keep it isolated.",
|
|
125
127
|
"akan.file.prototype-mutation": "Avoid prototype mutation, or isolate it in an approved low-level integration file.",
|
|
126
128
|
"akan.file.class-export-global-declaration": "Move the helper to a sibling file and import it into the class module.",
|
|
129
|
+
"akan.agent.unpublished-form-setter":
|
|
130
|
+
"Pass the setter by reference where the wrapper only forwards (onChange={st.do.setTitleOnTask}); move normalization into the control's own transform prop; move a multi-write into a _postSet<Field> hook on the store, keeping the generated setter on the control.",
|
|
127
131
|
"akan.file.component-internal-declaration":
|
|
128
132
|
"Move the type or helper to a type/util file in ui/, webkit/, or common/ by purpose. If it is the component's props, declare it as `interface <Component>Props`.",
|
|
129
133
|
"akan.file.component-export":
|
|
130
134
|
"Move the value or type to a util/constant/type file in ui/, webkit/, or common/ and import it. Adding `export` is not a valid fix — only PascalCase components and their `<Component>Props` interface belong here.",
|
|
131
135
|
"akan.layout.app-root-file":
|
|
132
136
|
"Move the file into a conventional app folder (common, env, lib, page, private, public, script, srvkit, ui, or webkit).",
|
|
137
|
+
"akan.layout.app-root-folder":
|
|
138
|
+
"Move the folder's contents into a conventional app folder (common, env, lib, page, private, public, script, srvkit, ui, or webkit) and delete it.",
|
|
133
139
|
"akan.layout.lib-root-file":
|
|
134
|
-
"Move the file into a
|
|
140
|
+
"Move the file into a conventional lib folder (common, env, lib, page, private, public, srvkit, ui, or webkit).",
|
|
141
|
+
"akan.layout.lib-root-folder":
|
|
142
|
+
"Move the folder's contents into a conventional lib folder (common, env, lib, page, private, public, srvkit, ui, or webkit) and delete it.",
|
|
143
|
+
"akan.layout.lib-facet-file":
|
|
144
|
+
"Move the file into a domain module folder under lib/; keep the lib facet root limited to generated support facets.",
|
|
135
145
|
"akan.layout.module-ui-file":
|
|
136
146
|
"Rename the file to an allowed module UI name, or move it to ui/ if it is not a module component.",
|
|
137
147
|
"akan.ssr.unnecessary-use-client":
|
|
@@ -146,12 +156,6 @@ const RULE_FIXES: Record<string, string> = {
|
|
|
146
156
|
"Add a <Model>.Unit.tsx for list/card rendering and a <Model>.View.tsx for the detail surface, then have the Zone delegate to them.",
|
|
147
157
|
"akan.ssr.template-client-state":
|
|
148
158
|
"Bind the field to the store instead: `value={xForm.field}` with `onChange={st.do.setFieldOnX}`.",
|
|
149
|
-
"akan.mcp.missing-description":
|
|
150
|
-
"Add `.desc([en, ko])` to this entry in the module's dictionary — for a slice, either on the slice entry or on the `<model>List<Slice>` endpoint it generates. Describe when to reach for it, not what it is named.",
|
|
151
|
-
"akan.agent.missing-store-description":
|
|
152
|
-
"Add the action to the module dictionary's `.store()` stage with a `.desc([en, ko])` saying what it does for the user — not what the endpoint it calls does. That stage is optional everywhere else: an action named after its endpoint already reads as that endpoint's description.",
|
|
153
|
-
"akan.mcp.unguarded-exposure":
|
|
154
|
-
"Name the guards in the same option object as `mcp`: `init({ guards: [SignedIn], mcp: { expose: true } })`. Write `guards: [Public]` if anonymous reads are the intent — the access is the same, but only one of the two is a decision. The `slice()` call's guards map reaches the root slice and base CRUD, never a named slice.",
|
|
155
159
|
};
|
|
156
160
|
|
|
157
161
|
function getRuleFix(rule: string): string | undefined {
|
|
@@ -181,9 +185,8 @@ export class AkanQualityScanner {
|
|
|
181
185
|
...sourceFiles.flatMap((sourceFile) => this.#scanConventionQuality(sourceFile)),
|
|
182
186
|
...sourceFiles.flatMap((sourceFile) => this.#scanLayoutQuality(sourceFile)),
|
|
183
187
|
...abstractFiles.flatMap((abstractFile) => this.#scanAbstractQuality(abstractFile)),
|
|
188
|
+
...new FormSetterScanner().scan(sourceFiles),
|
|
184
189
|
...ssr.warnings,
|
|
185
|
-
...new McpScanner().scan(sourceFiles),
|
|
186
|
-
...new StoreScanner().scan(sourceFiles),
|
|
187
190
|
];
|
|
188
191
|
|
|
189
192
|
return {
|
|
@@ -313,6 +316,7 @@ export class AkanQualityScanner {
|
|
|
313
316
|
});
|
|
314
317
|
}
|
|
315
318
|
|
|
319
|
+
warnings.push(...getBangCommentWarnings(sourceFile));
|
|
316
320
|
warnings.push(...getPlaceholderExportWarnings(sourceFile));
|
|
317
321
|
warnings.push(...getDictionaryTextWarnings(sourceFile));
|
|
318
322
|
warnings.push(...getGlobalMutationWarnings(sourceFile));
|
|
@@ -418,26 +422,31 @@ export class AkanQualityScanner {
|
|
|
418
422
|
}
|
|
419
423
|
|
|
420
424
|
#scanLayoutQuality(sourceFile: SourceFileInfo): QualityWarning[] {
|
|
421
|
-
const segments = sourceFile.file.split("/");
|
|
422
425
|
const warnings: QualityWarning[] = [];
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
426
|
+
const rootEntry = getSysRootEntry(sourceFile.file);
|
|
427
|
+
if (rootEntry) {
|
|
428
|
+
const { type, name, isDir } = rootEntry;
|
|
429
|
+
const allowed = isDir ? rootAllowedDirs[type].has(name) : rootAllowedFiles[type].has(name);
|
|
430
|
+
const kind = isDir ? "folder" : "file";
|
|
431
|
+
if (!allowed) {
|
|
432
|
+
warnings.push({
|
|
433
|
+
rule: `akan.layout.${type}-root-${kind}`,
|
|
434
|
+
scope: "layout",
|
|
435
|
+
severity: "warning",
|
|
436
|
+
file: sourceFile.file,
|
|
437
|
+
message: `Unexpected ${type} root ${kind} "${name}". Keep ${type} code in conventional ${type} folders.`,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
431
440
|
}
|
|
432
441
|
|
|
433
|
-
const
|
|
434
|
-
if (
|
|
442
|
+
const libFacetFile = getLibFacetRootFile(sourceFile.file);
|
|
443
|
+
if (libFacetFile && !isAllowedLibFacetRootFile(libFacetFile)) {
|
|
435
444
|
warnings.push({
|
|
436
|
-
rule: "akan.layout.lib-
|
|
445
|
+
rule: "akan.layout.lib-facet-file",
|
|
437
446
|
scope: "layout",
|
|
438
447
|
severity: "warning",
|
|
439
448
|
file: sourceFile.file,
|
|
440
|
-
message: `Unexpected lib root file "${
|
|
449
|
+
message: `Unexpected lib facet root file "${libFacetFile}". Keep direct lib/ root files limited to generated support facets.`,
|
|
441
450
|
});
|
|
442
451
|
}
|
|
443
452
|
|
|
@@ -590,9 +599,10 @@ function isEnumClassStatement(sourceFile: ts.SourceFile, statement: ts.Statement
|
|
|
590
599
|
|
|
591
600
|
function getExportedClassNames(sourceFile: ts.SourceFile) {
|
|
592
601
|
return sourceFile.statements
|
|
593
|
-
.filter((statement): statement is ts.ClassDeclaration => ts.isClassDeclaration(statement)
|
|
602
|
+
.filter((statement): statement is ts.ClassDeclaration => ts.isClassDeclaration(statement))
|
|
594
603
|
.filter((statement) => isExported(statement))
|
|
595
|
-
.map((statement) => statement.name
|
|
604
|
+
.map((statement) => statement.name?.text)
|
|
605
|
+
.filter((name): name is string => !!name);
|
|
596
606
|
}
|
|
597
607
|
|
|
598
608
|
function isComponentDeclarationFile(file: string) {
|
|
@@ -672,7 +682,43 @@ function isPascalCaseName(name: string) {
|
|
|
672
682
|
|
|
673
683
|
function isDefaultExportStatement(statement: ts.Statement) {
|
|
674
684
|
if (ts.isExportAssignment(statement)) return !statement.isExportEquals;
|
|
675
|
-
|
|
685
|
+
if (!ts.canHaveModifiers(statement)) return false;
|
|
686
|
+
return !!ts.getModifiers(statement)?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Bun's bundler keeps `//!` and `/*!` through minification (it reads them as the `@license` class), so a
|
|
691
|
+
* bang marker in browser-reachable code ships verbatim to every visitor. `no-bang-comment-in-client.grit`
|
|
692
|
+
* is the build gate; this exists because that rule cannot see the whole file.
|
|
693
|
+
*
|
|
694
|
+
* A comment is trivia, which Biome's GritQL exposes to no node pattern, so the rule matches through
|
|
695
|
+
* `file($name, $body)` — and `$body` is the module's *token* span. A marker above the first statement or
|
|
696
|
+
* below the last one sits in leading or trailing trivia and is unreachable from GritQL at all. Reading the
|
|
697
|
+
* raw text here covers both, and re-reporting the markers grit already catches costs nothing: the two run
|
|
698
|
+
* from different commands, and a file with one is failing lint either way.
|
|
699
|
+
*/
|
|
700
|
+
function getBangCommentWarnings(sourceFile: SourceFileInfo): QualityWarning[] {
|
|
701
|
+
if (!isClientReachableFile(sourceFile.file)) return [];
|
|
702
|
+
// Anchored to line start or to whitespace after code, so a literal like `"https://host//!path"` is not a hit.
|
|
703
|
+
return findPatternLines(sourceFile.content, /(?:^|[^\s/][ \t]+)\/[*/]!/).map((line) => ({
|
|
704
|
+
rule: "akan.file.bang-comment-in-client",
|
|
705
|
+
scope: "file" as const,
|
|
706
|
+
severity: "warning" as const,
|
|
707
|
+
file: sourceFile.file,
|
|
708
|
+
line,
|
|
709
|
+
message: "A `//!` or `/*!` marker survives minification and ships to the browser.",
|
|
710
|
+
}));
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** Mirrors the path scope of `no-bang-comment-in-client.grit` in `biome.base.json`. */
|
|
714
|
+
function isClientReachableFile(file: string) {
|
|
715
|
+
if (/\.(test|spec)\.tsx?$/.test(file)) return false;
|
|
716
|
+
if (/\.(constant|store)\.ts$/.test(file)) return true;
|
|
717
|
+
if (/\.(Template|Unit|Util|View|Zone)\.tsx$/.test(file)) return true;
|
|
718
|
+
const segments = file.split("/");
|
|
719
|
+
if (segments.includes("ui") || segments.includes("webkit")) return /\.tsx?$/.test(file);
|
|
720
|
+
if (segments.includes("common")) return file.endsWith(".ts");
|
|
721
|
+
return segments.includes("page") && file.endsWith(".tsx");
|
|
676
722
|
}
|
|
677
723
|
|
|
678
724
|
function getPlaceholderExportWarnings(sourceFile: SourceFileInfo): QualityWarning[] {
|
|
@@ -831,7 +877,19 @@ function getConventionDescription(suffix: (typeof CONVENTION_SUFFIXES)[number],
|
|
|
831
877
|
return `${modelName}Store`;
|
|
832
878
|
}
|
|
833
879
|
|
|
834
|
-
|
|
880
|
+
const sysRootTypes = { apps: "app", libs: "lib" } as const;
|
|
881
|
+
|
|
882
|
+
function getSysRootEntry(file: string) {
|
|
883
|
+
const segments = file.split("/");
|
|
884
|
+
const root = segments[0];
|
|
885
|
+
const name = segments[2];
|
|
886
|
+
if (!root || !name || segments.length < 3) return null;
|
|
887
|
+
const type = sysRootTypes[root as keyof typeof sysRootTypes];
|
|
888
|
+
if (!type) return null;
|
|
889
|
+
return { type, name, isDir: segments.length > 3 };
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function getLibFacetRootFile(file: string) {
|
|
835
893
|
const segments = file.split("/");
|
|
836
894
|
if ((segments[0] === "libs" || segments[0] === "apps") && segments.length === 4 && segments[2] === "lib")
|
|
837
895
|
return segments[3];
|
package/recipeScanner.ts
CHANGED
|
@@ -118,7 +118,10 @@ const extractVariants = (config: ts.ObjectLiteralExpression) => {
|
|
|
118
118
|
if (!ts.isPropertyAssignment(variant) || !isNamed(variant.name)) continue;
|
|
119
119
|
if (!ts.isObjectLiteralExpression(variant.initializer)) continue;
|
|
120
120
|
variants[propName(variant.name)] = variant.initializer.properties
|
|
121
|
-
.filter(
|
|
121
|
+
.filter(
|
|
122
|
+
(option): option is ts.PropertyAssignment & { name: ts.Identifier | ts.StringLiteral } =>
|
|
123
|
+
ts.isPropertyAssignment(option) && isNamed(option.name),
|
|
124
|
+
)
|
|
122
125
|
.map((option) => propName(option.name));
|
|
123
126
|
}
|
|
124
127
|
} else if (key === "defaultVariants" && ts.isObjectLiteralExpression(property.initializer)) {
|
|
@@ -39,3 +39,66 @@ describe("RouteSourceValidator", () => {
|
|
|
39
39
|
});
|
|
40
40
|
});
|
|
41
41
|
});
|
|
42
|
+
|
|
43
|
+
describe("RouteSourceValidator route chains", () => {
|
|
44
|
+
const chain = (source: string, kind: "page" | "layout", pattern: string) =>
|
|
45
|
+
RouteSourceValidator.validateRouteSourceExports(source, `page/${kind}.tsx`, kind, { pattern });
|
|
46
|
+
|
|
47
|
+
test("reads params, prompt and devOnly off a page() chain without evaluating it", () => {
|
|
48
|
+
const source = [
|
|
49
|
+
'import { page } from "akanjs/client";',
|
|
50
|
+
"export default page()",
|
|
51
|
+
' .param("projectId", ID, { desc: "The project." })',
|
|
52
|
+
' .search("status", String)',
|
|
53
|
+
" .config({ transition: 'stack', devOnly: true })",
|
|
54
|
+
' .prompt("briefProject", "Brief one project.")',
|
|
55
|
+
" .render(async ({ projectId }) => null);",
|
|
56
|
+
].join("\n");
|
|
57
|
+
expect(chain(source, "page", "/:lang/project/:projectId")).toEqual({
|
|
58
|
+
devOnly: true,
|
|
59
|
+
chain: { kind: "page", params: ["projectId"], prompt: "briefProject" },
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("names a [segment] the page did not declare and a .param() the path lacks", () => {
|
|
64
|
+
expect(() => chain("export default page().render(() => null);", "page", "/:lang/org/:orgId")).toThrow(
|
|
65
|
+
'sits under [orgId] but declares no .param("orgId")',
|
|
66
|
+
);
|
|
67
|
+
expect(() => chain('export default page().param("orgId", ID).render(() => null);', "page", "/:lang/org")).toThrow(
|
|
68
|
+
'declares .param("orgId") but no [orgId] segment is in its path',
|
|
69
|
+
);
|
|
70
|
+
expect(() =>
|
|
71
|
+
chain('export default page().param("lang", String).render(() => null);', "page", "/:lang/org"),
|
|
72
|
+
).toThrow('declares .param("lang"), which every route receives undeclared');
|
|
73
|
+
// A layout may leave a segment undeclared.
|
|
74
|
+
expect(() =>
|
|
75
|
+
chain("export default layout().render(({ children }) => children);", "layout", "/:lang/org/:orgId"),
|
|
76
|
+
).not.toThrow();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("refuses named exports beside a chain, a chain in the wrong file kind, and a computed name", () => {
|
|
80
|
+
expect(() =>
|
|
81
|
+
chain(["export default page().render(() => null);", "export const pageConfig = {};"].join("\n"), "page", "/"),
|
|
82
|
+
).toThrow("exports pageConfig beside its page() chain");
|
|
83
|
+
expect(() => chain("export default layout().render(() => null);", "page", "/")).toThrow(
|
|
84
|
+
"is a page file but exports layout()",
|
|
85
|
+
);
|
|
86
|
+
expect(() => chain("export default page().render(() => null);", "layout", "/")).toThrow(
|
|
87
|
+
"is a layout file but exports page()",
|
|
88
|
+
);
|
|
89
|
+
expect(() => chain("export default page().param(name, ID).render(() => null);", "page", "/:lang/:x")).toThrow(
|
|
90
|
+
".param() takes a string literal first",
|
|
91
|
+
);
|
|
92
|
+
expect(() => chain("export default page().config({ devOnly: flag }).render(() => null);", "page", "/")).toThrow(
|
|
93
|
+
"devOnly must be a literal true or false",
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("still reads the legacy shape as before", () => {
|
|
98
|
+
const source = [
|
|
99
|
+
"export default function Page() { return null; }",
|
|
100
|
+
"export const pageConfig = { devOnly: false };",
|
|
101
|
+
].join("\n");
|
|
102
|
+
expect(chain(source, "page", "/")).toEqual({ devOnly: false });
|
|
103
|
+
});
|
|
104
|
+
});
|