@akanjs/devkit 3.1.1-rc.9 → 3.1.2-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +117 -0
- package/agentAssets.test.ts +65 -0
- package/agentAssets.ts +166 -0
- package/agentsIndex.ts +4 -4
- package/akanApp/akanApp.host.test.ts +12 -0
- package/akanApp/akanApp.host.ts +12 -0
- package/akanConfig/akanConfig.test.ts +105 -0
- package/akanConfig/akanConfig.ts +21 -1
- package/akanConfig/productionLockfile.test.ts +31 -0
- package/akanConfig/productionLockfile.ts +2 -0
- package/akanContext.ts +129 -4
- package/akanMcpContract.test.ts +28 -1
- package/akanMcpContract.ts +51 -3
- package/applicationBuildRunner.test.ts +53 -0
- package/applicationBuildRunner.ts +7 -4
- package/artifact/implicitRootLayout.ts +4 -2
- package/biome.base.json +4 -1
- package/cloud/globalConfig.test.ts +34 -0
- package/cloud/globalConfig.ts +2 -2
- package/codeAgent/agent/AkanCodeEnv.ts +26 -0
- package/codeAgent/agent/AkanCodeServices.ts +5 -2
- package/codeAgent/agent/CodeAgent.ts +11 -0
- package/codeAgent/agent/CodeAgentGate.ts +156 -10
- package/codeAgent/agent/CodeAgentProxy.ts +5 -4
- package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
- package/codeAgent/agent/CodeAgentRpcListener.ts +102 -16
- package/codeAgent/agent/SubagentPool.ts +5 -0
- package/codeAgent/agent/WorkspaceTrust.ts +200 -0
- package/codeAgent/agent/akanCodePaths.ts +8 -1
- package/codeAgent/agent/akanSystemPrompt.ts +5 -1
- package/codeAgent/agent/codeAgent.test.ts +41 -2
- package/codeAgent/agent/codeAgentContract.test.ts +119 -0
- package/codeAgent/agent/codeAgentHosts.ts +30 -0
- package/codeAgent/agent/codeAgentProxy.test.ts +53 -1
- package/codeAgent/agent/codeAgentRpcListener.test.ts +115 -13
- package/codeAgent/agent/stringArg.ts +1 -1
- package/codeAgent/feedback/PreviewFeedback.ts +2 -2
- package/codeAgent/feedback/VerifyFeedback.ts +3 -4
- package/codeAgent/index.ts +2 -2
- package/codeAgent/skills/akan-agent/SKILL.md +43 -0
- package/codeAgent/skills/akan-code/SKILL.md +38 -11
- package/codeAgent/skills/akan-debug/SKILL.md +41 -6
- package/codeAgent/skills/akan-deps/SKILL.md +1 -2
- package/codeAgent/skills/akan-err/SKILL.md +2 -1
- package/codeAgent/skills/akan-mcp/SKILL.md +40 -0
- package/codeAgent/skills/akan-page/SKILL.md +44 -0
- package/codeAgent/skills/akan-query/SKILL.md +39 -0
- package/codeAgent/skills/akan-runtime/SKILL.md +43 -0
- package/codeAgent/skills/akan-scaffold/SKILL.md +16 -6
- package/codeAgent/skills/akan-transport/SKILL.md +40 -0
- package/codeAgent/skills/akan-validate/SKILL.md +13 -11
- package/codeAgent/tools/AkanCli.ts +2 -1
- package/codeAgent/tools/AkanCodePlugins.ts +5 -1
- package/codeAgent/tools/AkanEditScope.ts +8 -3
- package/codeAgent/tools/AkanToolPack.ts +4 -10
- package/codeAgent/tools/AkanVerifier.ts +13 -31
- package/codeAgent/tools/McpServerConfig.ts +6 -4
- package/codeAgent/tools/McpSignIn.ts +7 -7
- package/codeAgent/tools/McpTokenStore.ts +63 -16
- package/codeAgent/tools/McpToolPack.ts +8 -1
- package/codeAgent/tools/WebToolPack.ts +50 -6
- package/codeAgent/tools/akanCodePlugins.test.ts +130 -60
- package/codeAgent/tools/mcpOAuth.test.ts +50 -14
- package/codeAgent/tools/mcpServerConfig.test.ts +130 -1
- package/commandDecorators/command.ts +4 -1
- package/commandDecorators/workspaceVersionCheck.test.ts +17 -7
- package/commandDecorators/workspaceVersionCheck.ts +4 -1
- package/commentDensityScanner.test.ts +121 -0
- package/commentDensityScanner.ts +101 -0
- package/componentSizeScanner.test.ts +36 -0
- package/componentSizeScanner.ts +52 -0
- package/componentWriteChainScanner.test.ts +137 -0
- package/componentWriteChainScanner.ts +104 -0
- package/conventionSet.test.ts +73 -0
- package/conventionSet.ts +67 -0
- package/createTunnel.test.ts +200 -2
- package/createTunnel.ts +80 -6
- package/csrE2e/ssrRegistry.e2e.test.ts +6 -1
- package/deprecatedCommentScanner.test.ts +54 -0
- package/deprecatedCommentScanner.ts +30 -0
- package/endpointReach.test.ts +58 -0
- package/endpointReach.ts +80 -0
- package/envSeed.test.ts +90 -0
- package/envSeed.ts +68 -0
- package/executors.test.ts +19 -5
- package/executors.ts +59 -4
- package/fileSys.test.ts +30 -2
- package/fileSys.ts +14 -0
- package/formSetterScanner.test.ts +44 -1
- package/formSetterScanner.ts +53 -8
- package/frontendBuild/csrArtifactBuilder.ts +7 -0
- package/frontendBuild/mcpAppViewBuilder.test.ts +63 -0
- package/frontendBuild/mcpAppViewBuilder.ts +7 -1
- package/frontendBuild/optionalPeers.test.ts +132 -0
- package/index.ts +1 -0
- package/integration/devStability.integration.test.ts +9 -5
- package/integration/ssrMemoryProbe.ts +6 -0
- package/kindBranchScanner.test.ts +89 -0
- package/kindBranchScanner.ts +158 -0
- package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
- package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
- package/lint/no-oversized-list-limit.grit +73 -0
- package/lint/no-unguarded-endpoint.grit +5 -5
- package/linter.test.ts +12 -0
- package/linter.ts +35 -12
- package/maintainabilityBaseline.test.ts +280 -0
- package/maintainabilityBaseline.ts +220 -0
- package/maintainabilityContract.test.ts +846 -0
- package/maintainabilityContract.ts +404 -0
- package/maintainabilityExceptionScanner.test.ts +90 -0
- package/maintainabilityExceptionScanner.ts +85 -0
- package/maintainabilityLimits.ts +60 -0
- package/maintainabilityReport.test.ts +130 -0
- package/maintainabilityReport.ts +153 -0
- package/mobile/desktopRelease.test.ts +1 -1
- package/mobile/desktopRelease.ts +3 -1
- package/mobile/desktopServerStage.test.ts +1 -0
- package/mobile/desktopServerStage.ts +3 -1
- package/mobile/nativeApp.test.ts +20 -5
- package/mobile/nativeApp.ts +4 -1
- package/mobile/nativeConfig.test.ts +209 -5
- package/mobile/nativeConfig.ts +127 -6
- package/mobile/nativePublicEnv.test.ts +2 -0
- package/mobile/nativePublicEnv.ts +13 -0
- package/package.json +4 -2
- package/platformTest/LinuxDockerTarget.ts +3 -0
- package/{codeAgent/feedback → preview}/PreviewChrome.ts +13 -7
- package/preview/PreviewView.ts +219 -0
- package/preview/index.ts +2 -0
- package/preview/previewView.test.ts +76 -0
- package/prompter.ts +7 -0
- package/qualityScanner.test.ts +75 -0
- package/qualityScanner.ts +17 -1
- package/routeSourceValidator.test.ts +11 -2
- package/routeSourceValidator.ts +14 -2
- package/serviceLoopCallScanner.test.ts +56 -0
- package/serviceLoopCallScanner.ts +122 -0
- package/shellOnlyEnv.test.ts +65 -0
- package/shellOnlyEnv.ts +54 -0
- package/signalMutationIndex.test.ts +15 -0
- package/signalMutationIndex.ts +23 -82
- package/sourceComments.test.ts +45 -0
- package/sourceComments.ts +70 -0
- package/sshHostKeys.ts +59 -0
- package/storeAccessScanner.test.ts +84 -0
- package/storeAccessScanner.ts +71 -0
- package/storeSource.ts +160 -0
- package/storeWriteScanner.test.ts +292 -0
- package/storeWriteScanner.ts +50 -0
- package/subspace.test.ts +10 -0
- package/subspaceClone.ts +3 -1
- package/subspaceConfig.ts +6 -0
- package/testOnlyExportScanner.test.ts +83 -0
- package/testOnlyExportScanner.ts +124 -0
- package/transforms/barrelImportsPlugin.ts +10 -4
- package/transforms/mcpAppPageTrimmer.test.ts +8 -2
- package/transforms/mcpAppPageTrimmer.ts +5 -20
- package/transforms/pureLazyMarker.ts +1 -1
- package/typeChecker.ts +32 -0
- package/typecheck/projectTypecheck.test.ts +18 -0
- package/typecheck/projectTypecheck.ts +29 -5
- package/typecheck/typecheck.proc.ts +6 -0
- package/typecheck/typecheckFailure.ts +13 -0
- package/types.ts +1 -0
- package/workflow/artifacts.ts +16 -2
- package/workflow/executor.ts +22 -7
- package/workflow/guardCatalog.test.ts +59 -0
- package/workflow/guardCatalog.ts +108 -0
- package/workflow/index.ts +4 -0
- package/workflow/interactionScaffold.ts +122 -0
- package/workflow/mutationScaffold.ts +533 -0
- package/workflow/mutationSource.test.ts +108 -0
- package/workflow/mutationSource.ts +219 -0
- package/workflow/plan.ts +82 -0
- package/workflow/render.ts +19 -4
- package/workflow/source.test.ts +185 -0
- package/workflow/source.ts +151 -24
- package/workflow/sourceAst.ts +10 -48
- package/workflow/types.ts +18 -1
- package/workspaceLayout.ts +2 -0
- package/writePath.test.ts +109 -0
- package/writePath.ts +219 -0
- package/writePlacementScanner.test.ts +77 -0
- package/writePlacementScanner.ts +50 -0
- package/writingActionIndex.test.ts +99 -0
- package/writingActionIndex.ts +63 -0
- package/codeAgent/feedback/PreviewView.ts +0 -95
- /package/{codeAgent/feedback → preview}/previewChrome.test.ts +0 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// A client list request takes at most MAX_PAGE_SIZE (500) rows: SignalResolver.#clientPageLimit
|
|
5
|
+
// (pkgs/akanjs/server/resolver/signal.resolver.ts) clamps a larger limit and answers with 500 rows and no
|
|
6
|
+
// error, so a screen that asked for 10000 silently stops at row 500. A store list reads hasMoreOf<Model><Suffix>
|
|
7
|
+
// as true after a full 500, so only a screen that calls loadMoreOf<Model><Suffix> ever sees the rest. The server
|
|
8
|
+
// logs one warning per list; this rule moves it to the line that asked.
|
|
9
|
+
//
|
|
10
|
+
// Matched: a literal limit above 500 in the generated list fetch, `fetch.<model>List<Suffix>(...sliceArgs,
|
|
11
|
+
// skip, limit, sort?, fetchPolicy?)`; as `limit:` in the option object of `fetch.init<Model><Suffix>`,
|
|
12
|
+
// `fetch.get<Model>Init<Suffix>`, `st.do.init<Model><Suffix>` and `st.do.refresh<Model><Suffix>`; and as the
|
|
13
|
+
// argument of `st.do.setLimitOf<Model><Suffix>`.
|
|
14
|
+
//
|
|
15
|
+
// A rule cannot know a slice's arity, so the positional limit is found by shape: an argument that reads as a
|
|
16
|
+
// skip (`0`, `null`, `undefined`, a name with skip/offset, a page multiplied out) directly before the literal,
|
|
17
|
+
// and after it nothing but a non-numeric sort and a fetch policy. That keeps a numeric slice argument out
|
|
18
|
+
// (`fetch.statListInYear(2026)`, `fetch.statListInMonth(11, 2026)`), as it does a variable limit, `limit: 0`
|
|
19
|
+
// (the store's one-page mode, which the server warns about itself) and a `limit:` key anywhere else (rate
|
|
20
|
+
// limits). Left over: a slice whose own last two arguments read as `(0, 1000)`, such as a price range; suppress
|
|
21
|
+
// that one with `// biome-ignore lint/plugin: <reason>`.
|
|
22
|
+
pattern over_max_page_size() {
|
|
23
|
+
JsNumberLiteralExpression() as $size where {
|
|
24
|
+
$size <: r"(?:50[1-9]|5[1-9][0-9]|[6-9][0-9]{2}|[1-9][0-9]{0,2}(?:_?[0-9]{3})+|[1-9]e\+?(?:[3-9]|[1-9][0-9]+))"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
pattern page_skip() {
|
|
29
|
+
or { r"(?:0|null|undefined)", r"(?i)[\s\S]*(?:skip|offset)[\s\S]*", r"(?i)[\s\S]*page[\s\S]*\*[\s\S]*" }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
pattern page_sort() {
|
|
33
|
+
not JsNumberLiteralExpression()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
JsCallExpression(callee = $callee, arguments = JsCallArguments(args = $args)) where {
|
|
37
|
+
or {
|
|
38
|
+
and {
|
|
39
|
+
$callee <: `fetch.$endpoint`,
|
|
40
|
+
$endpoint <: r"[a-z][A-Za-z0-9_$]*List(?:[A-Z][A-Za-z0-9_$]*)?",
|
|
41
|
+
$args <: or {
|
|
42
|
+
[..., page_skip(), over_max_page_size() as $limit],
|
|
43
|
+
[..., page_skip(), over_max_page_size() as $limit, page_sort()],
|
|
44
|
+
[..., page_skip(), over_max_page_size() as $limit, page_sort(), $_]
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
and {
|
|
48
|
+
or {
|
|
49
|
+
and {
|
|
50
|
+
$callee <: `fetch.$endpoint`,
|
|
51
|
+
$endpoint <: or { r"init[A-Z][A-Za-z0-9_$]*", r"get[A-Z][A-Za-z0-9_$]*Init(?:[A-Z][A-Za-z0-9_$]*)?" }
|
|
52
|
+
},
|
|
53
|
+
and { $callee <: `st.do.$action`, $action <: r"(?:init|refresh)[A-Z][A-Za-z0-9_$]*" }
|
|
54
|
+
},
|
|
55
|
+
$args <: some JsObjectExpression(
|
|
56
|
+
members = some JsPropertyObjectMember(
|
|
57
|
+
name = r"(?:limit|\x22limit\x22|'limit')",
|
|
58
|
+
value = over_max_page_size() as $limit
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
},
|
|
62
|
+
and {
|
|
63
|
+
$callee <: `st.do.$action`,
|
|
64
|
+
$action <: r"setLimitOf[A-Z][A-Za-z0-9_$]*",
|
|
65
|
+
$args <: [over_max_page_size() as $limit, ...]
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
register_diagnostic(
|
|
69
|
+
span = $limit,
|
|
70
|
+
message = "The server serves at most 500 rows per list request and drops the rest without an error, so this list silently stops at row 500. Page through it at 500 or fewer per request (skip/limit, or st.do.loadMoreOf<Model><Suffix>() while hasMoreOf<Model><Suffix>), or compute what the screen needs on the server in an insight or a dedicated endpoint.",
|
|
71
|
+
severity = "error"
|
|
72
|
+
)
|
|
73
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
engine biome(1.0)
|
|
2
2
|
language js(typescript, jsx)
|
|
3
3
|
|
|
4
|
-
// `SignalContext`
|
|
5
|
-
//
|
|
4
|
+
// `SignalContext` refuses an endpoint, a named slice, or a slice's root query declaring no guards, and outside local
|
|
5
|
+
// operation the server does not boot with one. An option the rule cannot read, a variable or a spread, is left alone.
|
|
6
6
|
or {
|
|
7
7
|
JsCallExpression(callee = $callee, arguments = JsCallArguments(args = $args)) as $call where {
|
|
8
8
|
or {
|
|
@@ -32,7 +32,7 @@ or {
|
|
|
32
32
|
},
|
|
33
33
|
register_diagnostic(
|
|
34
34
|
span = $callee,
|
|
35
|
-
message = "No `guards`, so every
|
|
35
|
+
message = "No `guards`, so every call to this endpoint is refused with 403, and outside local operation the server does not boot. Name the guards it needs, or write `guards: [Public]` when anonymous access is the intent.",
|
|
36
36
|
severity = "warn"
|
|
37
37
|
)
|
|
38
38
|
},
|
|
@@ -47,7 +47,7 @@ or {
|
|
|
47
47
|
},
|
|
48
48
|
register_diagnostic(
|
|
49
49
|
span = $sliceOption,
|
|
50
|
-
message = "No `guards` on this slice, so
|
|
50
|
+
message = "No `guards` on this slice, so it mounts no root list or insight and none of its generated reads and writes. Pass { guards: { root: Admin, get: …, cru: … } }, naming `Public` where anonymous access is the intent.",
|
|
51
51
|
severity = "warn"
|
|
52
52
|
)
|
|
53
53
|
},
|
|
@@ -64,7 +64,7 @@ or {
|
|
|
64
64
|
},
|
|
65
65
|
register_diagnostic(
|
|
66
66
|
span = $guardMap,
|
|
67
|
-
message = "No `root` guard on this slice,
|
|
67
|
+
message = "No `root` guard on this slice. Left out, it mounts no root list or insight; `root: []` refuses every caller with 403, and outside local operation the server does not boot. Write `root: Admin`, or `root: Public` for anonymous access.",
|
|
68
68
|
severity = "warn"
|
|
69
69
|
)
|
|
70
70
|
}
|
package/linter.test.ts
CHANGED
|
@@ -93,4 +93,16 @@ describe("Linter", () => {
|
|
|
93
93
|
process.env.PATH = pathBefore;
|
|
94
94
|
}
|
|
95
95
|
});
|
|
96
|
+
|
|
97
|
+
test("splits path arguments into batches that stay under the command-line budget", () => {
|
|
98
|
+
const args = Array.from({ length: 10 }, (_, idx) => `apps/demo/lib/file${idx}.ts`);
|
|
99
|
+
const batches = Linter.batchArgs(args, 100);
|
|
100
|
+
expect(batches.flat()).toEqual(args);
|
|
101
|
+
expect(batches.length).toBeGreaterThan(1);
|
|
102
|
+
for (const batch of batches) expect(batch.reduce((sum, arg) => sum + arg.length + 3, 0)).toBeLessThanOrEqual(100);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("keeps an argument longer than the budget in a batch of its own", () => {
|
|
106
|
+
expect(Linter.batchArgs(["a".repeat(50), "b", "c"], 10)).toEqual([["a".repeat(50)], ["b", "c"]]);
|
|
107
|
+
});
|
|
96
108
|
});
|
package/linter.ts
CHANGED
|
@@ -386,27 +386,50 @@ export class Linter {
|
|
|
386
386
|
return { ...lintResult, fixed: stdout !== source, output: stdout };
|
|
387
387
|
}
|
|
388
388
|
|
|
389
|
-
//
|
|
390
|
-
// decline a file).
|
|
389
|
+
// As few spawns as the command line allows: Biome's startup dominates a small file. Missing paths are dropped (a
|
|
390
|
+
// template may decline a file).
|
|
391
391
|
async fixFiles(filePaths: string[]): Promise<{ fixed: string[] }> {
|
|
392
392
|
const resolved = filePaths.map((filePath) => this.#resolveFilePath(filePath)).filter(existsSync);
|
|
393
393
|
if (resolved.length === 0) return { fixed: [] };
|
|
394
394
|
const before = new Map(resolved.map((filePath) => [filePath, readFileSync(filePath, "utf8")]));
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
395
|
+
const biomePaths = resolved.map((filePath) => this.#toBiomePath(filePath));
|
|
396
|
+
for (const batch of Linter.batchArgs(biomePaths, Linter.#pathArgsBudget))
|
|
397
|
+
await this.#runBiome([
|
|
398
|
+
"check",
|
|
399
|
+
"--write",
|
|
400
|
+
"--reporter=json",
|
|
401
|
+
"--max-diagnostics=none",
|
|
402
|
+
"--no-errors-on-unmatched",
|
|
403
|
+
"--config-path",
|
|
404
|
+
this.configPath,
|
|
405
|
+
...batch,
|
|
406
|
+
]);
|
|
405
407
|
return {
|
|
406
408
|
fixed: resolved.filter((filePath) => readFileSync(filePath, "utf8") !== before.get(filePath)),
|
|
407
409
|
};
|
|
408
410
|
}
|
|
409
411
|
|
|
412
|
+
//? Windows refuses a command line past 32,767 characters (ENAMETOOLONG), and a whole workspace scaffold passes it.
|
|
413
|
+
static readonly #pathArgsBudget = 24_000;
|
|
414
|
+
|
|
415
|
+
static batchArgs(args: string[], budget: number): string[][] {
|
|
416
|
+
const batches: string[][] = [];
|
|
417
|
+
let batch: string[] = [];
|
|
418
|
+
let length = 0;
|
|
419
|
+
for (const arg of args) {
|
|
420
|
+
const cost = arg.length + 3;
|
|
421
|
+
if (batch.length > 0 && length + cost > budget) {
|
|
422
|
+
batches.push(batch);
|
|
423
|
+
batch = [];
|
|
424
|
+
length = 0;
|
|
425
|
+
}
|
|
426
|
+
batch.push(arg);
|
|
427
|
+
length += cost;
|
|
428
|
+
}
|
|
429
|
+
if (batch.length > 0) batches.push(batch);
|
|
430
|
+
return batches;
|
|
431
|
+
}
|
|
432
|
+
|
|
410
433
|
async getConfigForFile(filePath: string): Promise<unknown> {
|
|
411
434
|
const resolvedFilePath = this.#resolveFilePath(filePath);
|
|
412
435
|
if (!existsSync(resolvedFilePath)) throw new Error(`File not found: ${filePath}`);
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { MaintainabilityBaseline } from "./maintainabilityBaseline";
|
|
3
|
+
import { type DebtRuleId, MaintainabilityContract, type MaintainabilityViolation } from "./maintainabilityContract";
|
|
4
|
+
|
|
5
|
+
const sizeOf = (key: string, lineCount: number): MaintainabilityViolation => ({
|
|
6
|
+
kind: "size",
|
|
7
|
+
rule: key.includes("#") ? "akan.size.component" : "akan.size.file",
|
|
8
|
+
key,
|
|
9
|
+
file: key.split("#")[0] ?? key,
|
|
10
|
+
line: 1,
|
|
11
|
+
lineCount,
|
|
12
|
+
maxLines: 400,
|
|
13
|
+
});
|
|
14
|
+
const writeOf = (key: string, writeCount = 2): MaintainabilityViolation => ({
|
|
15
|
+
kind: "multiWrite",
|
|
16
|
+
rule: "akan.store.multi-write",
|
|
17
|
+
key,
|
|
18
|
+
file: key.split("#")[0] ?? key,
|
|
19
|
+
line: 1,
|
|
20
|
+
owner: key.split("#")[1] ?? "",
|
|
21
|
+
writes: [],
|
|
22
|
+
writeCount,
|
|
23
|
+
});
|
|
24
|
+
const accessOf = (key: string, accessCount: number): MaintainabilityViolation => ({
|
|
25
|
+
kind: "storeAccess",
|
|
26
|
+
rule: "akan.component.store-access",
|
|
27
|
+
key,
|
|
28
|
+
file: key.split("#")[0] ?? key,
|
|
29
|
+
line: 1,
|
|
30
|
+
component: key.split("#")[1] ?? "",
|
|
31
|
+
accessCount,
|
|
32
|
+
maxAccesses: 16,
|
|
33
|
+
});
|
|
34
|
+
const placementOf = (file: string, writes: string[]): MaintainabilityViolation => ({
|
|
35
|
+
kind: "writePlacement",
|
|
36
|
+
rule: "akan.write.placement",
|
|
37
|
+
key: file,
|
|
38
|
+
file,
|
|
39
|
+
line: 1,
|
|
40
|
+
writes,
|
|
41
|
+
});
|
|
42
|
+
const baselineOf = (
|
|
43
|
+
sections: { [rule in DebtRuleId]?: Record<string, number> },
|
|
44
|
+
reasons: Record<string, string> = {},
|
|
45
|
+
) =>
|
|
46
|
+
new MaintainabilityBaseline(
|
|
47
|
+
new Map(Object.entries(sections).map(([rule, counts]) => [rule as DebtRuleId, new Map(Object.entries(counts))])),
|
|
48
|
+
new Map(Object.entries(reasons)),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
describe("MaintainabilityBaseline", () => {
|
|
52
|
+
test("keeps one section per rule the gate can fail, the growth check aside", () => {
|
|
53
|
+
expect(new Set(MaintainabilityBaseline.debtRules)).toEqual(
|
|
54
|
+
new Set(MaintainabilityContract.ruleIds.filter((rule) => rule !== "akan.baseline.growth")),
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("allows a recorded entry up to its count, writes included, and nothing it does not record", () => {
|
|
59
|
+
const baseline = MaintainabilityBaseline.parse(
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
"akan.size.file": { "lib/a/A.Zone.tsx": 900 },
|
|
62
|
+
"akan.store.multi-write": { "lib/a/a.store.ts#run": 2 },
|
|
63
|
+
"akan.write.placement": { "ui/Board.tsx": 1 },
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
expect(baseline.allows(sizeOf("lib/a/A.Zone.tsx", 900))).toBe(true);
|
|
67
|
+
expect(baseline.allows(sizeOf("lib/a/A.Zone.tsx", 901))).toBe(false);
|
|
68
|
+
expect(baseline.allows(sizeOf("lib/b/B.Zone.tsx", 401))).toBe(false);
|
|
69
|
+
expect(baseline.allows(writeOf("lib/a/a.store.ts#run"))).toBe(true);
|
|
70
|
+
expect(baseline.allows(writeOf("lib/a/a.store.ts#run", 3))).toBe(false);
|
|
71
|
+
expect(baseline.allows(writeOf("lib/a/a.store.ts#other"))).toBe(false);
|
|
72
|
+
expect(baseline.allows(placementOf("ui/Board.tsx", ["st.do.removeBoard"]))).toBe(true);
|
|
73
|
+
expect(baseline.allows(placementOf("ui/Board.tsx", ["st.do.removeBoard", "fetch.placeBoard"]))).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("records the busiest of several breaches under one key", () => {
|
|
77
|
+
const chainOf = (writeCount: number): MaintainabilityViolation => ({
|
|
78
|
+
kind: "multiWrite",
|
|
79
|
+
rule: "akan.component.write-chain",
|
|
80
|
+
key: "lib/a/A.Zone.tsx#Canvas",
|
|
81
|
+
file: "lib/a/A.Zone.tsx",
|
|
82
|
+
line: 1,
|
|
83
|
+
owner: "Canvas",
|
|
84
|
+
writes: [],
|
|
85
|
+
writeCount,
|
|
86
|
+
});
|
|
87
|
+
const baseline = MaintainabilityBaseline.of([chainOf(2), chainOf(4)]);
|
|
88
|
+
expect([...(baseline.sections.get("akan.component.write-chain") ?? [])]).toEqual([["lib/a/A.Zone.tsx#Canvas", 4]]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("tightens to what still breaks: lowers a shrunk entry, keeps a grown one, drops the rest", () => {
|
|
92
|
+
const baseline = baselineOf({
|
|
93
|
+
"akan.size.file": { "lib/a/A.Zone.tsx": 900, "lib/b/B.Zone.tsx": 700, "lib/gone/Gone.Zone.tsx": 600 },
|
|
94
|
+
"akan.store.multi-write": { "lib/a/a.store.ts#run": 3, "lib/a/a.store.ts#fixed": 2 },
|
|
95
|
+
});
|
|
96
|
+
const tightened = baseline.tightenedBy([
|
|
97
|
+
sizeOf("lib/a/A.Zone.tsx", 850),
|
|
98
|
+
sizeOf("lib/b/B.Zone.tsx", 720),
|
|
99
|
+
sizeOf("lib/c/C.Zone.tsx", 500),
|
|
100
|
+
writeOf("lib/a/a.store.ts#run", 2),
|
|
101
|
+
]);
|
|
102
|
+
expect([...(tightened.sections.get("akan.size.file") ?? [])]).toEqual([
|
|
103
|
+
["lib/a/A.Zone.tsx", 850],
|
|
104
|
+
["lib/b/B.Zone.tsx", 700],
|
|
105
|
+
]);
|
|
106
|
+
expect([...(tightened.sections.get("akan.store.multi-write") ?? [])]).toEqual([["lib/a/a.store.ts#run", 2]]);
|
|
107
|
+
expect(baseline.tightenedBy([]).isEmpty).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("names every entry a working baseline added or raised over the reference one, per rule", () => {
|
|
111
|
+
const committed = baselineOf({
|
|
112
|
+
"akan.size.file": { "lib/a/A.Zone.tsx": 900 },
|
|
113
|
+
"akan.store.multi-write": { "a.store.ts#run": 2 },
|
|
114
|
+
});
|
|
115
|
+
const working = baselineOf({
|
|
116
|
+
"akan.size.file": { "lib/a/A.Zone.tsx": 950, "lib/b/B.Zone.tsx": 500 },
|
|
117
|
+
"akan.store.multi-write": { "a.store.ts#run": 3, "a.store.ts#sneak": 2 },
|
|
118
|
+
});
|
|
119
|
+
expect(working.grownFrom(committed)).toEqual([
|
|
120
|
+
{ rule: "akan.size.file", key: "lib/a/A.Zone.tsx", count: 950, committed: 900 },
|
|
121
|
+
{ rule: "akan.size.file", key: "lib/b/B.Zone.tsx", count: 500, committed: undefined },
|
|
122
|
+
{ rule: "akan.store.multi-write", key: "a.store.ts#run", count: 3, committed: 2 },
|
|
123
|
+
{ rule: "akan.store.multi-write", key: "a.store.ts#sneak", count: 2, committed: undefined },
|
|
124
|
+
]);
|
|
125
|
+
expect(committed.tightenedBy([sizeOf("lib/a/A.Zone.tsx", 800)]).grownFrom(committed)).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("ratchets a component's store accesses like a size: allows up to its entry, lowers it, refuses a raise", () => {
|
|
129
|
+
const baseline = MaintainabilityBaseline.parse(
|
|
130
|
+
JSON.stringify({
|
|
131
|
+
"akan.component.store-access": { "lib/a/A.Zone.tsx#Canvas": 40, "lib/gone/G.Zone.tsx#Old": 30 },
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
expect(baseline.allows(accessOf("lib/a/A.Zone.tsx#Canvas", 40))).toBe(true);
|
|
135
|
+
expect(baseline.allows(accessOf("lib/a/A.Zone.tsx#Canvas", 41))).toBe(false);
|
|
136
|
+
expect(baseline.allows(accessOf("lib/b/B.Zone.tsx#Board", 17))).toBe(false);
|
|
137
|
+
expect(baseline.recordedOf(accessOf("lib/a/A.Zone.tsx#Canvas", 41))).toBe(40);
|
|
138
|
+
|
|
139
|
+
const tightened = baseline.tightenedBy([accessOf("lib/a/A.Zone.tsx#Canvas", 33)]);
|
|
140
|
+
expect([...(tightened.sections.get("akan.component.store-access") ?? [])]).toEqual([
|
|
141
|
+
["lib/a/A.Zone.tsx#Canvas", 33],
|
|
142
|
+
]);
|
|
143
|
+
expect(baseline.grownFrom(tightened)).toEqual([
|
|
144
|
+
{ rule: "akan.component.store-access", key: "lib/a/A.Zone.tsx#Canvas", count: 40, committed: 33 },
|
|
145
|
+
{ rule: "akan.component.store-access", key: "lib/gone/G.Zone.tsx#Old", count: 30, committed: undefined },
|
|
146
|
+
]);
|
|
147
|
+
expect(tightened.grownFrom(baseline)).toEqual([]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("lets a shape entry grow only with a reason of ten non-space characters, a write entry never", () => {
|
|
151
|
+
const committed = baselineOf({
|
|
152
|
+
"akan.size.file": { "lib/a/A.Zone.tsx": 900 },
|
|
153
|
+
"akan.store.multi-write": { "a.store.ts#run": 2 },
|
|
154
|
+
});
|
|
155
|
+
const reason = "The canvas is one pointer state machine.";
|
|
156
|
+
const working = baselineOf(
|
|
157
|
+
{
|
|
158
|
+
"akan.size.file": { "lib/a/A.Zone.tsx": 950, "ui/Board.tsx": 450, "lib/b/B.Zone.tsx": 500 },
|
|
159
|
+
"akan.size.component": { "lib/c/C.Zone.tsx#Grid": 300 },
|
|
160
|
+
"akan.component.store-access": { "lib/d/D.Zone.tsx#Canvas": 30 },
|
|
161
|
+
"akan.store.multi-write": { "a.store.ts#run": 2, "a.store.ts#sneak": 2 },
|
|
162
|
+
"akan.component.write-chain": { "lib/c/C.Zone.tsx#Grid": 2 },
|
|
163
|
+
"akan.write.placement": { "ui/Board.tsx": 1 },
|
|
164
|
+
"akan.service.call-in-loop": { "lib/e/e.service.ts#sync": 1 },
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
"lib/a/A.Zone.tsx": reason,
|
|
168
|
+
"ui/Board.tsx": reason,
|
|
169
|
+
"lib/b/B.Zone.tsx": "a b c d e f g h i",
|
|
170
|
+
"lib/c/C.Zone.tsx#Grid": reason,
|
|
171
|
+
"lib/d/D.Zone.tsx#Canvas": reason,
|
|
172
|
+
"a.store.ts#sneak": reason,
|
|
173
|
+
"lib/e/e.service.ts#sync": reason,
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
expect(working.grownFrom(committed)).toEqual([
|
|
178
|
+
{ rule: "akan.size.file", key: "lib/b/B.Zone.tsx", count: 500, committed: undefined },
|
|
179
|
+
{ rule: "akan.store.multi-write", key: "a.store.ts#sneak", count: 2, committed: undefined },
|
|
180
|
+
{ rule: "akan.component.write-chain", key: "lib/c/C.Zone.tsx#Grid", count: 2, committed: undefined },
|
|
181
|
+
{ rule: "akan.write.placement", key: "ui/Board.tsx", count: 1, committed: undefined },
|
|
182
|
+
]);
|
|
183
|
+
expect(working.reasonOf("lib/b/B.Zone.tsx")).toBeUndefined();
|
|
184
|
+
expect(working.reasonOf("a.store.ts#sneak")).toBeUndefined();
|
|
185
|
+
expect(working.reasonOf("lib/d/D.Zone.tsx#Canvas")).toBe(reason);
|
|
186
|
+
expect(working.reasonOf("lib/e/e.service.ts#sync")).toBe(reason);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("keeps a reason only beside a shape entry it still describes", () => {
|
|
190
|
+
const reason = "The canvas is one pointer state machine.";
|
|
191
|
+
const baseline = baselineOf(
|
|
192
|
+
{
|
|
193
|
+
"akan.size.file": { "lib/a/A.Zone.tsx": 900, "lib/gone/Gone.Zone.tsx": 600 },
|
|
194
|
+
"akan.component.store-access": { "lib/a/A.Zone.tsx#Canvas": 30 },
|
|
195
|
+
"akan.store.multi-write": { "lib/a/a.store.ts#run": 2 },
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
"lib/a/A.Zone.tsx": reason,
|
|
199
|
+
"lib/gone/Gone.Zone.tsx": reason,
|
|
200
|
+
"lib/a/A.Zone.tsx#Canvas": reason,
|
|
201
|
+
"lib/a/a.store.ts#run": reason,
|
|
202
|
+
"lib/never/Recorded.tsx": reason,
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const tightened = baseline.tightenedBy([
|
|
207
|
+
sizeOf("lib/a/A.Zone.tsx", 850),
|
|
208
|
+
accessOf("lib/a/A.Zone.tsx#Canvas", 20),
|
|
209
|
+
writeOf("lib/a/a.store.ts#run"),
|
|
210
|
+
]);
|
|
211
|
+
|
|
212
|
+
expect([...(tightened.sections.get("akan.size.file") ?? [])]).toEqual([["lib/a/A.Zone.tsx", 850]]);
|
|
213
|
+
expect([...tightened.reasons]).toEqual([
|
|
214
|
+
["lib/a/A.Zone.tsx", reason],
|
|
215
|
+
["lib/a/A.Zone.tsx#Canvas", reason],
|
|
216
|
+
]);
|
|
217
|
+
expect(baseline.tightenedBy([]).reasons.size).toBe(0);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("prints the rule sections in rule order with sorted keys, as Biome would, and reprints them unchanged", () => {
|
|
221
|
+
const baseline = MaintainabilityBaseline.of([
|
|
222
|
+
writeOf("lib/z/z.store.ts#run"),
|
|
223
|
+
sizeOf("lib/court/Court.Zone.tsx#Canvas", 640),
|
|
224
|
+
sizeOf("lib/court/Court.Zone.tsx", 947),
|
|
225
|
+
writeOf("lib/court/court.store.ts#assignCourtPcOnCourt", 3),
|
|
226
|
+
placementOf("ui/Board.tsx", ["st.do.removeBoard"]),
|
|
227
|
+
]);
|
|
228
|
+
const text = baseline.stringify();
|
|
229
|
+
expect(text).toBe(`{
|
|
230
|
+
"akan.size.file": {
|
|
231
|
+
"lib/court/Court.Zone.tsx": 947
|
|
232
|
+
},
|
|
233
|
+
"akan.size.component": {
|
|
234
|
+
"lib/court/Court.Zone.tsx#Canvas": 640
|
|
235
|
+
},
|
|
236
|
+
"akan.store.multi-write": {
|
|
237
|
+
"lib/court/court.store.ts#assignCourtPcOnCourt": 3,
|
|
238
|
+
"lib/z/z.store.ts#run": 2
|
|
239
|
+
},
|
|
240
|
+
"akan.write.placement": {
|
|
241
|
+
"ui/Board.tsx": 1
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
`);
|
|
245
|
+
expect(MaintainabilityBaseline.parse(text).stringify()).toBe(text);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("prints the reasons last and only once there are some, reprinting them unchanged", () => {
|
|
249
|
+
const text = `{
|
|
250
|
+
"akan.size.file": {
|
|
251
|
+
"lib/a/A.Zone.tsx": 950,
|
|
252
|
+
"ui/Board.tsx": 450
|
|
253
|
+
},
|
|
254
|
+
"reasons": {
|
|
255
|
+
"lib/a/A.Zone.tsx": "The canvas is one pointer state machine.",
|
|
256
|
+
"ui/Board.tsx": "Generated SVG path table for the court layout."
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
`;
|
|
260
|
+
const reasonless = '{\n "akan.size.file": {\n "lib/a/A.Zone.tsx": 950\n }\n}\n';
|
|
261
|
+
|
|
262
|
+
expect(MaintainabilityBaseline.parse(text).stringify()).toBe(text);
|
|
263
|
+
expect(MaintainabilityBaseline.parse(reasonless).stringify()).toBe(reasonless);
|
|
264
|
+
expect(MaintainabilityBaseline.parse('{ "reasons": {} }').stringify()).not.toContain("reasons");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("refuses a file that is not the baseline shape, naming it", () => {
|
|
268
|
+
expect(() => MaintainabilityBaseline.parse("{", "apps/demo/akan.baseline.json")).toThrow(
|
|
269
|
+
"apps/demo/akan.baseline.json is not valid JSON.",
|
|
270
|
+
);
|
|
271
|
+
expect(() => MaintainabilityBaseline.parse("[]")).toThrow("holds an object");
|
|
272
|
+
expect(() => MaintainabilityBaseline.parse('{ "akan.size.file": { "a": "900" } }')).toThrow('"akan.size.file"');
|
|
273
|
+
expect(() => MaintainabilityBaseline.parse('{ "akan.store.multi-write": ["a#b"] }')).toThrow(
|
|
274
|
+
'"akan.store.multi-write"',
|
|
275
|
+
);
|
|
276
|
+
expect(() => MaintainabilityBaseline.parse('{ "maxLines": {} }')).toThrow("akan quality baseline --force");
|
|
277
|
+
expect(() => MaintainabilityBaseline.parse('{ "reasons": { "ui/Board.tsx": 1 } }')).toThrow('"reasons"');
|
|
278
|
+
expect(MaintainabilityBaseline.parse("{}").isEmpty).toBe(true);
|
|
279
|
+
});
|
|
280
|
+
});
|