@akanjs/devkit 3.1.1-rc.15 → 3.1.1-rc.17
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/agentAssets.test.ts +65 -0
- package/agentAssets.ts +166 -0
- package/agentsIndex.ts +4 -4
- package/akanContext.ts +44 -2
- package/applicationBuildRunner.test.ts +17 -0
- package/applicationBuildRunner.ts +3 -1
- package/biome.base.json +4 -1
- package/codeAgent/agent/akanSystemPrompt.ts +1 -1
- package/codeAgent/feedback/VerifyFeedback.ts +3 -4
- package/codeAgent/skills/akan-agent/SKILL.md +36 -0
- package/codeAgent/skills/akan-debug/SKILL.md +3 -3
- package/codeAgent/skills/akan-deps/SKILL.md +1 -2
- package/codeAgent/skills/akan-mcp/SKILL.md +40 -0
- package/codeAgent/skills/akan-page/SKILL.md +44 -0
- package/codeAgent/skills/akan-query/SKILL.md +38 -0
- package/codeAgent/skills/akan-runtime/SKILL.md +40 -0
- package/codeAgent/skills/akan-scaffold/SKILL.md +16 -6
- package/codeAgent/skills/akan-transport/SKILL.md +40 -0
- package/codeAgent/skills/akan-validate/SKILL.md +13 -11
- package/codeAgent/tools/AkanCli.ts +2 -1
- package/codeAgent/tools/AkanEditScope.ts +8 -3
- package/codeAgent/tools/AkanToolPack.ts +4 -10
- package/codeAgent/tools/AkanVerifier.ts +13 -31
- package/codeAgent/tools/akanCodePlugins.test.ts +12 -30
- 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/deprecatedCommentScanner.test.ts +54 -0
- package/deprecatedCommentScanner.ts +30 -0
- package/endpointReach.test.ts +58 -0
- package/endpointReach.ts +80 -0
- package/executors.test.ts +0 -4
- package/index.ts +1 -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/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/package.json +2 -2
- package/prompter.ts +7 -0
- package/qualityScanner.test.ts +75 -0
- package/qualityScanner.ts +17 -1
- package/serviceLoopCallScanner.test.ts +56 -0
- package/serviceLoopCallScanner.ts +122 -0
- package/sourceComments.test.ts +45 -0
- package/sourceComments.ts +70 -0
- package/storeAccessScanner.test.ts +84 -0
- package/storeAccessScanner.ts +71 -0
- package/storeSource.ts +159 -0
- package/storeWriteScanner.test.ts +292 -0
- package/storeWriteScanner.ts +50 -0
- package/testOnlyExportScanner.test.ts +83 -0
- package/testOnlyExportScanner.ts +124 -0
- package/typeChecker.ts +32 -0
- package/typecheck/projectTypecheck.test.ts +18 -0
- package/typecheck/projectTypecheck.ts +29 -5
- package/typecheck/typecheck.proc.ts +6 -0
- package/typecheck/typecheckFailure.ts +13 -0
- package/workflow/artifacts.ts +16 -2
- package/workflow/executor.ts +22 -7
- package/workflow/guardCatalog.test.ts +59 -0
- package/workflow/guardCatalog.ts +108 -0
- package/workflow/index.ts +4 -0
- package/workflow/interactionScaffold.ts +122 -0
- package/workflow/mutationScaffold.ts +519 -0
- package/workflow/mutationSource.test.ts +108 -0
- package/workflow/mutationSource.ts +219 -0
- package/workflow/plan.ts +54 -0
- package/workflow/render.ts +19 -4
- package/workflow/source.test.ts +185 -0
- package/workflow/source.ts +151 -24
- package/workflow/types.ts +18 -1
- package/workspaceLayout.ts +2 -0
- package/writePath.test.ts +109 -0
- package/writePath.ts +219 -0
- package/writePlacementScanner.test.ts +77 -0
- package/writePlacementScanner.ts +50 -0
- package/writingActionIndex.test.ts +99 -0
- package/writingActionIndex.ts +63 -0
|
@@ -43,8 +43,8 @@ Each result names the next call in `next.tool`. Follow it; do not improvise.
|
|
|
43
43
|
and each workflow's inputs.)
|
|
44
44
|
2. `apply_workflow({ planPath })` — writes the scaffold and syncs the generated barrels. Returns
|
|
45
45
|
`validationTarget`.
|
|
46
|
-
3. `run_validation({ runIdOrPlan: <validationTarget> })`, or
|
|
47
|
-
|
|
46
|
+
3. `run_validation({ runIdOrPlan: <validationTarget> })`, or run `akan verify` (`akan_verify` in akan code);
|
|
47
|
+
both are the whole chain in one process.
|
|
48
48
|
4. **Read each scaffolded file before editing it.** They are a few lines each and they extend base classes
|
|
49
49
|
that already generated the CRUD surface. Read the `akan-store` skill before touching a store or adding
|
|
50
50
|
signal endpoints, or you will redeclare `create<Model>` and hit an "already exists" typecheck error.
|
|
@@ -60,9 +60,19 @@ Each result names the next call in `next.tool`. Follow it; do not improvise.
|
|
|
60
60
|
- Hand-writing files is the fallback, used only when no workflow matches, or when apply reports
|
|
61
61
|
unsupported / no-op / failed diagnostics telling you to act manually. Say which case applied.
|
|
62
62
|
- Never hand-edit a generated file to "finish" a scaffold — use `repair_generated` or `akan sync <app>`.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
|
|
64
|
+
## No workflow tools? The CLI is the same chain
|
|
65
|
+
|
|
66
|
+
The tools come from `akan mcp` (`--mode plan` reads, `--mode apply` writes). When they are not connected, each
|
|
67
|
+
has a command that prints the same report:
|
|
68
|
+
|
|
69
|
+
| tool | command |
|
|
70
|
+
| --- | --- |
|
|
71
|
+
| `list_workflows` / `explain_workflow` | `akan workflow list` / `akan workflow explain <name>` |
|
|
72
|
+
| `plan_workflow` | `akan workflow plan create-module --app <app> --module comment --format json --out <path>` |
|
|
73
|
+
| `apply_workflow` | `akan workflow apply <path> --format json` (`--dry-run` previews) |
|
|
74
|
+
| `run_validation` | `akan verify <app>` |
|
|
75
|
+
| `repair_generated` / `repair_imports` / `repair_module_shape` | `akan repair generated\|imports\|module-shape --app <app>` |
|
|
66
76
|
|
|
67
77
|
## Worked example — "an intro page, two detail pages, and a comment zone with a database"
|
|
68
78
|
|
|
@@ -73,4 +83,4 @@ Each result names the next call in `next.tool`. Follow it; do not improvise.
|
|
|
73
83
|
leave the store body empty unless you need extra state.
|
|
74
84
|
4. Write the pages and the comment UI against the generated actions — `st.do.createComment`,
|
|
75
85
|
`st.do.initCommentInPublic`, `st.use.commentListInPublic`.
|
|
76
|
-
5. `
|
|
86
|
+
5. `akan verify` until it prints `verify ok`.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: akan-transport
|
|
3
|
+
description: "Guard and tune endpoints on the wire: writing a Can<Verb><Model> guard, deadlines, cache, idempotency, rate limits, server-to-server calls, body types, bytes and HTTP verbs."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Akan transport
|
|
7
|
+
|
|
8
|
+
## Guards
|
|
9
|
+
|
|
10
|
+
- Every custom `query` / `mutation` / `pubsub` / `message` names its own `guards: [...]`; every `slice()` takes an
|
|
11
|
+
explicit guard map with `root: Admin`. `Public` belongs on a slice `get:`, and on a mutation only for an act that
|
|
12
|
+
must work signed out (sign-up, sign-in, token refresh, an OAuth route), which also declares a `rateLimit`.
|
|
13
|
+
- A resource guard is a `Can<Verb><Model>` class in the model library's `srvkit/guards.ts` that fails closed:
|
|
14
|
+
no resource named, or a load that throws, is `false` (after a `logger.warn`). Admin bypass goes first.
|
|
15
|
+
- Every guard declares `static scope`: `"account"` reads only the caller (`SignedIn`, `Admin`, roles), and
|
|
16
|
+
`"resource"` needs the call's arguments (every `Can<Verb><Model>`). Keep `static name = "User";` with a comment.
|
|
17
|
+
- Guards read `context.get<T>("account")`, serve HTTP and websocket alike, and stay side-effect free. The acting
|
|
18
|
+
user comes from `.with(Self)` / `.with(CurrentUserId)` / `.with(Me)`, the address from `.with(Ip)` — never a
|
|
19
|
+
client-supplied id. The service re-checks ownership: two independent gates.
|
|
20
|
+
|
|
21
|
+
## Endpoint options
|
|
22
|
+
|
|
23
|
+
- `{ timeout: <ms> }` is the deadline on both ends; undeclared, the client quits at 30 s and the handler is not
|
|
24
|
+
cancelled.
|
|
25
|
+
- `{ cache }` only on a `query` with no internal argument, capped at one day; `invalidate(endpoint, args?)` drops
|
|
26
|
+
it early through a plugged `EndpointCacheControl`.
|
|
27
|
+
- `{ rateLimit: { calls, windowMs?, by? } }` per endpoint, `option.setRateLimit` per app, `plug(RateLimit)` for a
|
|
28
|
+
business key.
|
|
29
|
+
- A mutation repeated under one `Idempotency-Key` header runs once and replays its first answer.
|
|
30
|
+
- `{ method: "PATCH" | "PUT" | "DELETE" }` moves a mutation off `POST` — only when a foreign protocol forces it.
|
|
31
|
+
- `errors: { 409: ["ticket.error.locked"] }` names the errors an endpoint throws on purpose.
|
|
32
|
+
|
|
33
|
+
## Bodies and other servers
|
|
34
|
+
|
|
35
|
+
- `.body()` / `.param()` take scalars, model refs or `enumOf(...)`; `Int` / `Float`, never `Number`; bytes are
|
|
36
|
+
`Binary`, never `Any`; `Upload` only in a `fileUpload: true` mutation.
|
|
37
|
+
- Server to server, `fetch.clone({ origin })` opens no socket without `connect: true`, retries through `retry`,
|
|
38
|
+
and rethrows a remote `Err` as it is — never wrap or re-key it.
|
|
39
|
+
|
|
40
|
+
Full detail: `get_guideline` with `transportRule`, or `mcpRule` for what a guard's scope does to an MCP listing.
|
|
@@ -7,17 +7,19 @@ description: "Run the akan check loop after editing and fix each failure class t
|
|
|
7
7
|
|
|
8
8
|
## One call does the whole chain
|
|
9
9
|
|
|
10
|
-
`
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
- `akan
|
|
19
|
-
- `akan
|
|
20
|
-
- `akan build
|
|
10
|
+
`akan verify` (the `akan_verify` tool in akan code) syncs, lints with fixes, typechecks, fast-builds each
|
|
11
|
+
changed app, and runs the SSR check for a `.tsx` change — over everything the working tree has changed, in one
|
|
12
|
+
process. It prints one line when it passes and one line per problem when it fails. **Run it after editing,
|
|
13
|
+
before you say you are done.** In akan code it also runs itself at the end of a turn that changed source.
|
|
14
|
+
|
|
15
|
+
Never run `akan lint`, `akan typecheck` and `akan build` in turn: that is the same work three times over, at
|
|
16
|
+
three times the output. Narrow the one command instead:
|
|
17
|
+
|
|
18
|
+
- `akan verify <app-or-lib-or-pkg>` — that target whole, changed or not.
|
|
19
|
+
- `akan verify --lint-only` — the quick pass between edits.
|
|
20
|
+
- `akan verify --no-build` — leave the app build out.
|
|
21
|
+
- `akan verify --max 0` — every line, when the report ends in `+N more`.
|
|
22
|
+
- `fixed N files` in the header means lint rewrote them on disk: re-read before editing them again.
|
|
21
23
|
|
|
22
24
|
Raw `tsc` misses the generated barrels and reports false errors. Do not reach for it.
|
|
23
25
|
|
|
@@ -24,7 +24,8 @@ export class AkanCli {
|
|
|
24
24
|
cwd: this.#cwd,
|
|
25
25
|
stdout: "pipe",
|
|
26
26
|
stderr: "pipe",
|
|
27
|
-
|
|
27
|
+
//? A workspace .env's AKAN_PUBLIC_LOG_LEVEL=verbose reaches the child too, and buried its error under 90 lines.
|
|
28
|
+
env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1", AKAN_LOG_STDOUT_LEVEL: "warn" },
|
|
28
29
|
});
|
|
29
30
|
const timer = setTimeout(() => proc.kill(), timeoutMs);
|
|
30
31
|
const [stdout, stderr, exitCode] = await Promise.all([
|
|
@@ -2,12 +2,13 @@ export interface AkanEditScopeResult {
|
|
|
2
2
|
paths: string[];
|
|
3
3
|
apps: string[];
|
|
4
4
|
libs: string[];
|
|
5
|
+
pkgs: string[];
|
|
5
6
|
/** A created, deleted or renamed file under `apps/**` or `libs/**` — the only kind a barrel has to catch up with. */
|
|
6
7
|
needsSync: boolean;
|
|
7
8
|
touchesTsx: boolean;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
const empty: AkanEditScopeResult = { paths: [], apps: [], libs: [], needsSync: false, touchesTsx: false };
|
|
11
|
+
const empty: AkanEditScopeResult = { paths: [], apps: [], libs: [], pkgs: [], needsSync: false, touchesTsx: false };
|
|
11
12
|
|
|
12
13
|
// Read from git, not tool events: an edit made through `bash` (a heredoc, sed, a codemod) produces no write tool call.
|
|
13
14
|
export class AkanEditScope {
|
|
@@ -21,6 +22,7 @@ export class AkanEditScope {
|
|
|
21
22
|
paths,
|
|
22
23
|
apps: AkanEditScope.#targets(paths, "apps"),
|
|
23
24
|
libs: AkanEditScope.#targets(paths, "libs"),
|
|
25
|
+
pkgs: AkanEditScope.#targets(paths, "pkgs"),
|
|
24
26
|
needsSync: entries.some((entry) => /[ARD?]/.test(entry.code) && /^(apps|libs)\//.test(entry.file)),
|
|
25
27
|
touchesTsx: paths.some((file) => file.endsWith(".tsx")),
|
|
26
28
|
};
|
|
@@ -38,10 +40,13 @@ export class AkanEditScope {
|
|
|
38
40
|
return new Set(text.split("\n").filter((line) => line.trim().length > 3));
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
static #targets(paths: string[], root: "apps" | "libs") {
|
|
43
|
+
static #targets(paths: string[], root: "apps" | "libs" | "pkgs") {
|
|
42
44
|
const names = paths
|
|
43
45
|
.filter((file) => file.startsWith(`${root}/`))
|
|
44
|
-
.map((file) =>
|
|
46
|
+
.map((file) => {
|
|
47
|
+
const [, first = "", second = ""] = file.split("/");
|
|
48
|
+
return first.startsWith("@") && second ? `${first}/${second}` : first;
|
|
49
|
+
})
|
|
45
50
|
.filter(Boolean);
|
|
46
51
|
return [...new Set(names)];
|
|
47
52
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI, InlineExtension } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import type { Workspace } from "../../commandDecorators";
|
|
4
|
-
import { AkanEditScope } from "./AkanEditScope";
|
|
5
4
|
import { AkanVerifier } from "./AkanVerifier";
|
|
6
5
|
|
|
7
6
|
export interface AkanToolPackOptions {
|
|
@@ -66,17 +65,12 @@ export class AkanToolPack {
|
|
|
66
65
|
name: AkanToolPack.verifyToolName,
|
|
67
66
|
label: "akan verify",
|
|
68
67
|
description:
|
|
69
|
-
"Run the
|
|
70
|
-
promptSnippet: "akan_verify:
|
|
68
|
+
"Run `akan verify` over everything the working tree has changed: sync, lint (with fixes), typecheck, a fast build of each changed app, and the SSR check for .tsx changes, in one call. It prints one line when it passes and one line per problem when it fails. Call this after editing, before you finish; never run lint, typecheck or build separately.",
|
|
69
|
+
promptSnippet: "akan_verify: sync, lint, typecheck and build what the working tree changed, in one call",
|
|
71
70
|
parameters: Type.Object({}),
|
|
72
71
|
execute: async () => {
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
return {
|
|
76
|
-
content: [{ type: "text", text: AkanVerifier.summarize(report) }],
|
|
77
|
-
details: undefined,
|
|
78
|
-
isError: !report.ok,
|
|
79
|
-
};
|
|
72
|
+
const report = await verifier.verify();
|
|
73
|
+
return { content: [{ type: "text", text: report.text }], details: undefined, isError: !report.ok };
|
|
80
74
|
},
|
|
81
75
|
});
|
|
82
76
|
}
|
|
@@ -1,48 +1,30 @@
|
|
|
1
1
|
import { AkanCli, type AkanCliResult } from "./AkanCli";
|
|
2
|
-
import type { AkanEditScopeResult } from "./AkanEditScope";
|
|
3
2
|
|
|
4
3
|
export interface AkanVerifyReport {
|
|
5
4
|
ok: boolean;
|
|
6
|
-
|
|
7
|
-
scope: AkanEditScopeResult;
|
|
5
|
+
text: string;
|
|
8
6
|
}
|
|
9
7
|
|
|
10
8
|
export class AkanVerifier {
|
|
9
|
+
static readonly tailLines = 20;
|
|
11
10
|
readonly #cli: AkanCli;
|
|
12
11
|
|
|
13
12
|
constructor(cwd: string) {
|
|
14
13
|
this.#cli = new AkanCli(cwd);
|
|
15
14
|
}
|
|
16
15
|
|
|
17
|
-
async verify(
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
if (scope.needsSync) for (const target of targets) steps.push(await this.#cli.run(["sync", target]));
|
|
22
|
-
for (const target of targets) steps.push(await this.#cli.run(["lint", target]));
|
|
23
|
-
for (const app of scope.apps) steps.push(await this.#cli.run(["typecheck", app]));
|
|
24
|
-
if (scope.touchesTsx) steps.push(await this.#cli.run(["quality", "ssr"]));
|
|
25
|
-
return { ok: steps.every((step) => step.ok), steps, scope };
|
|
16
|
+
async verify(paths: string[] = []): Promise<AkanVerifyReport> {
|
|
17
|
+
const args = ["verify", ...(paths.length ? ["--files", paths.join(",")] : [])];
|
|
18
|
+
const result = await this.#cli.run(args, { timeoutMs: 600_000 });
|
|
19
|
+
return { ok: result.ok, text: AkanVerifier.summarize(result) };
|
|
26
20
|
}
|
|
27
21
|
|
|
28
|
-
|
|
29
|
-
static summarize(
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return [
|
|
36
|
-
`Verification failed: ${failed.map((step) => step.command).join(", ")}.`,
|
|
37
|
-
passed.length ? `Passed: ${passed.join(", ")}.` : "",
|
|
38
|
-
...blocks,
|
|
39
|
-
]
|
|
40
|
-
.filter(Boolean)
|
|
41
|
-
.join("\n\n");
|
|
22
|
+
//* `akan verify` already prints the agent-sized report; only a crash before it prints needs trimming.
|
|
23
|
+
static summarize(result: AkanCliResult) {
|
|
24
|
+
if (result.output.startsWith("verify ")) return result.output;
|
|
25
|
+
const lines = result.output.split("\n");
|
|
26
|
+
const tail =
|
|
27
|
+
lines.length <= AkanVerifier.tailLines ? result.output : `…\n${lines.slice(-AkanVerifier.tailLines).join("\n")}`;
|
|
28
|
+
return `${result.command} exited ${result.exitCode} before reporting:\n${tail}`;
|
|
42
29
|
}
|
|
43
30
|
}
|
|
44
|
-
|
|
45
|
-
const tail = (text: string, lines: number) => {
|
|
46
|
-
const all = text.split("\n");
|
|
47
|
-
return all.length <= lines ? text : `…\n${all.slice(-lines).join("\n")}`;
|
|
48
|
-
};
|
|
@@ -111,38 +111,18 @@ describe("TurnFeedback", () => {
|
|
|
111
111
|
});
|
|
112
112
|
|
|
113
113
|
describe("AkanVerifier.summarize", () => {
|
|
114
|
-
|
|
114
|
+
test("passes the verify report through untouched", () => {
|
|
115
|
+
const output =
|
|
116
|
+
"verify fail · a · lint ok · typecheck 1 error · 4.1s\napps/a/x.ts:1:7 TS2322 Type 'string' is not assignable to type 'number'.";
|
|
117
|
+
expect(AkanVerifier.summarize({ command: "akan verify", ok: false, exitCode: 1, output })).toBe(output);
|
|
118
|
+
});
|
|
115
119
|
|
|
116
|
-
test("
|
|
120
|
+
test("keeps only the tail of a crash that never reached the report", () => {
|
|
117
121
|
const output = [...Array(100).keys()].map((n) => `line ${n}`).join("\n");
|
|
118
|
-
const text = AkanVerifier.summarize(
|
|
119
|
-
|
|
120
|
-
ok: false,
|
|
121
|
-
scope,
|
|
122
|
-
steps: [
|
|
123
|
-
{ command: "akan lint a", ok: true, exitCode: 0, output: "" },
|
|
124
|
-
{ command: "akan typecheck a", ok: false, exitCode: 1, output },
|
|
125
|
-
],
|
|
126
|
-
},
|
|
127
|
-
{ tailLines: 5 },
|
|
128
|
-
);
|
|
129
|
-
expect(text).toContain("Verification failed: akan typecheck a");
|
|
130
|
-
expect(text).toContain("Passed: akan lint a");
|
|
122
|
+
const text = AkanVerifier.summarize({ command: "akan verify", ok: false, exitCode: 1, output });
|
|
123
|
+
expect(text).toStartWith("akan verify exited 1 before reporting:");
|
|
131
124
|
expect(text).toContain("line 99");
|
|
132
|
-
expect(text).not.toContain("line
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
test("says so plainly when everything passed", () => {
|
|
136
|
-
const text = AkanVerifier.summarize({
|
|
137
|
-
ok: true,
|
|
138
|
-
scope,
|
|
139
|
-
steps: [{ command: "akan lint a", ok: true, exitCode: 0, output: "" }],
|
|
140
|
-
});
|
|
141
|
-
expect(text).toContain("Verification passed");
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
test("an untouched workspace is not a failure", () => {
|
|
145
|
-
expect(AkanVerifier.summarize({ ok: true, scope, steps: [] })).toContain("nothing to verify");
|
|
125
|
+
expect(text).not.toContain("line 79\n");
|
|
146
126
|
});
|
|
147
127
|
});
|
|
148
128
|
|
|
@@ -153,9 +133,11 @@ describe("AkanEditScope", () => {
|
|
|
153
133
|
const baseline = await AkanEditScope.baseline(root);
|
|
154
134
|
await writeText(path.join(root, "apps", "demo", "lib", "post", "post.service.ts"), "export {};\n");
|
|
155
135
|
await writeText(path.join(root, "libs", "shared", "ui", "Card.tsx"), "export const Card = () => null;\n");
|
|
136
|
+
await writeText(path.join(root, "pkgs", "@akanjs", "cli", "index.ts"), "export {};\n");
|
|
156
137
|
const scope = await AkanEditScope.since(root, baseline);
|
|
157
138
|
expect(scope.apps).toEqual(["demo"]);
|
|
158
139
|
expect(scope.libs).toEqual(["shared"]);
|
|
140
|
+
expect(scope.pkgs).toEqual(["@akanjs/cli"]);
|
|
159
141
|
expect(scope.needsSync).toBe(true);
|
|
160
142
|
expect(scope.touchesTsx).toBe(true);
|
|
161
143
|
});
|
|
@@ -165,7 +147,7 @@ describe("AkanEditScope", () => {
|
|
|
165
147
|
await Bun.spawn(["git", "init", "-q"], { cwd: root, stdout: "ignore", stderr: "ignore" }).exited;
|
|
166
148
|
const baseline = await AkanEditScope.baseline(root);
|
|
167
149
|
const scope = await AkanEditScope.since(root, baseline);
|
|
168
|
-
expect(scope).toEqual({ paths: [], apps: [], libs: [], needsSync: false, touchesTsx: false });
|
|
150
|
+
expect(scope).toEqual({ paths: [], apps: [], libs: [], pkgs: [], needsSync: false, touchesTsx: false });
|
|
169
151
|
});
|
|
170
152
|
});
|
|
171
153
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { CommentDensityScanner } from "./commentDensityScanner";
|
|
4
|
+
import type { SourceFileInfo } from "./qualityScanner";
|
|
5
|
+
|
|
6
|
+
const fileOf = (file: string, content: string): SourceFileInfo => ({
|
|
7
|
+
file,
|
|
8
|
+
absolutePath: `/repo/${file}`,
|
|
9
|
+
content,
|
|
10
|
+
sourceFile: ts.createSourceFile(
|
|
11
|
+
file,
|
|
12
|
+
content,
|
|
13
|
+
ts.ScriptTarget.Latest,
|
|
14
|
+
true,
|
|
15
|
+
file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
|
16
|
+
),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const codeOf = (lineNum: number) => Array.from({ length: lineNum }, (_, idx) => `const value${idx} = ${idx};`);
|
|
20
|
+
const commentsOf = (lineNum: number) => Array.from({ length: lineNum }, (_, idx) => `// note ${idx}`);
|
|
21
|
+
const scan = (files: Record<string, string[]>) =>
|
|
22
|
+
new CommentDensityScanner().scan(Object.entries(files).map(([file, lines]) => fileOf(file, `${lines.join("\n")}\n`)));
|
|
23
|
+
|
|
24
|
+
describe("CommentDensityScanner", () => {
|
|
25
|
+
test("flags a module over 1% with at least five comment lines, summed across its files", () => {
|
|
26
|
+
const warnings = scan({
|
|
27
|
+
"apps/demo/lib/post/post.service.ts": [...commentsOf(3), ...codeOf(100)],
|
|
28
|
+
"apps/demo/lib/post/post.document.ts": [...codeOf(50), ...commentsOf(2)],
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(warnings).toHaveLength(1);
|
|
32
|
+
expect(warnings[0]).toMatchObject({
|
|
33
|
+
rule: "akan.comment.density",
|
|
34
|
+
scope: "comment",
|
|
35
|
+
file: "apps/demo/lib/post/post.service.ts",
|
|
36
|
+
line: 1,
|
|
37
|
+
});
|
|
38
|
+
expect(warnings[0]?.message).toContain("apps/demo/lib/post is 3.23% comments (5 of 155 non-blank lines)");
|
|
39
|
+
expect(warnings[0]?.locations).toEqual([
|
|
40
|
+
{ file: "apps/demo/lib/post/post.service.ts", line: 1 },
|
|
41
|
+
{ file: "apps/demo/lib/post/post.document.ts", line: 51 },
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("passes a module under 1%, and a small module with fewer than five comment lines", () => {
|
|
46
|
+
expect(
|
|
47
|
+
scan({
|
|
48
|
+
"apps/demo/lib/post/post.service.ts": [...commentsOf(5), ...codeOf(600)],
|
|
49
|
+
"apps/demo/lib/tag/tag.service.ts": [...commentsOf(4), ...codeOf(10)],
|
|
50
|
+
}),
|
|
51
|
+
).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("does not count lint and TS directives or the store scaffold markers", () => {
|
|
55
|
+
expect(
|
|
56
|
+
scan({
|
|
57
|
+
"apps/demo/lib/post/post.store.ts": [
|
|
58
|
+
"export class PostStore extends store(sig.post, () => ({",
|
|
59
|
+
" // state",
|
|
60
|
+
"})) {",
|
|
61
|
+
" // action",
|
|
62
|
+
"}",
|
|
63
|
+
"// biome-ignore lint/style/noNonNullAssertion: vendor typing",
|
|
64
|
+
"/* biome-ignore lint/plugin: fixed brand color */",
|
|
65
|
+
"// @ts-expect-error vendor typing",
|
|
66
|
+
"// @ts-ignore vendor typing",
|
|
67
|
+
...codeOf(5),
|
|
68
|
+
],
|
|
69
|
+
}),
|
|
70
|
+
).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("does not count comment syntax inside strings or JSX text", () => {
|
|
74
|
+
expect(
|
|
75
|
+
scan({
|
|
76
|
+
"apps/demo/ui/Links.tsx": [
|
|
77
|
+
'export const home = "https://host//home";',
|
|
78
|
+
'export const docs = "https://host//docs";',
|
|
79
|
+
`export const api = \`https://host//\${home}\`;`,
|
|
80
|
+
"export const Links = () => (",
|
|
81
|
+
" <p>",
|
|
82
|
+
" // not a comment",
|
|
83
|
+
" // still text",
|
|
84
|
+
" </p>",
|
|
85
|
+
");",
|
|
86
|
+
],
|
|
87
|
+
}),
|
|
88
|
+
).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("measures neither srvkit nor guards.ts nor tests", () => {
|
|
92
|
+
expect(
|
|
93
|
+
scan({
|
|
94
|
+
"apps/demo/srvkit/vendorApi.ts": [...commentsOf(20), ...codeOf(10)],
|
|
95
|
+
"apps/demo/lib/post/guards.ts": [...commentsOf(20), ...codeOf(10)],
|
|
96
|
+
"apps/demo/lib/post/post.signal.test.ts": [...commentsOf(20), ...codeOf(10)],
|
|
97
|
+
"apps/demo/ui/Card.test.tsx": [...commentsOf(20), ...codeOf(10)],
|
|
98
|
+
}),
|
|
99
|
+
).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("aggregates a ui folder as one entry and a ui file as its own", () => {
|
|
103
|
+
const warnings = scan({
|
|
104
|
+
"apps/demo/ui/Chart/Bar.tsx": [...commentsOf(3), ...codeOf(20)],
|
|
105
|
+
"apps/demo/ui/Chart/Pie.tsx": [...commentsOf(3), ...codeOf(20)],
|
|
106
|
+
"apps/demo/ui/Badge.tsx": [...commentsOf(4), ...codeOf(20)],
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(warnings.map((warning) => warning.message.split(" is ")[0])).toEqual(["apps/demo/ui/Chart"]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("leaves a recipe's one-line JSDoc description uncounted", () => {
|
|
113
|
+
const recipeOf = (name: string) => [`/** ${name} look. */`, `export const ${name}Recipe = recipe(tv({}));`];
|
|
114
|
+
expect(
|
|
115
|
+
scan({
|
|
116
|
+
"apps/demo/ui/Recipe/panel.ts": [...recipeOf("panel"), ...recipeOf("tile"), ...recipeOf("card")],
|
|
117
|
+
"apps/demo/ui/Recipe/chip.ts": [...recipeOf("chip"), ...recipeOf("pill"), ...recipeOf("tag")],
|
|
118
|
+
}),
|
|
119
|
+
).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
|
|
4
|
+
import { type SourceComment, SourceComments } from "./sourceComments";
|
|
5
|
+
|
|
6
|
+
interface CommentedFile {
|
|
7
|
+
file: string;
|
|
8
|
+
line: number;
|
|
9
|
+
commentLineNum: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface CommentTally {
|
|
13
|
+
lineNum: number;
|
|
14
|
+
commentLineNum: number;
|
|
15
|
+
files: CommentedFile[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class CommentDensityScanner {
|
|
19
|
+
static readonly rule = "akan.comment.density";
|
|
20
|
+
static readonly maxShare = 0.01;
|
|
21
|
+
static readonly minCommentLineNum = 5;
|
|
22
|
+
static #directive =
|
|
23
|
+
/^(?:biome-ignore|@ts-(?:expect-error|ignore|nocheck|check)\b|eslint-(?:disable|enable)|<reference\b)/;
|
|
24
|
+
static #scaffoldMarkers = new Set(["state", "action"]);
|
|
25
|
+
|
|
26
|
+
scan(sourceFiles: SourceFileInfo[]): QualityWarning[] {
|
|
27
|
+
const tallies = new Map<string, CommentTally>();
|
|
28
|
+
for (const sourceFile of sourceFiles) {
|
|
29
|
+
const unit = CommentDensityScanner.#unitOf(sourceFile.file);
|
|
30
|
+
if (!unit || !CommentDensityScanner.#isMeasured(sourceFile)) continue;
|
|
31
|
+
const tally = tallies.get(unit) ?? { lineNum: 0, commentLineNum: 0, files: [] };
|
|
32
|
+
const commentLines = CommentDensityScanner.#commentLinesOf(sourceFile);
|
|
33
|
+
tally.lineNum += sourceFile.content.split(/\r?\n/).filter((line) => line.trim() !== "").length;
|
|
34
|
+
tally.commentLineNum += commentLines.length;
|
|
35
|
+
if (commentLines[0] !== undefined)
|
|
36
|
+
tally.files.push({ file: sourceFile.file, line: commentLines[0], commentLineNum: commentLines.length });
|
|
37
|
+
tallies.set(unit, tally);
|
|
38
|
+
}
|
|
39
|
+
return [...tallies]
|
|
40
|
+
.filter(([, tally]) => CommentDensityScanner.#isDense(tally))
|
|
41
|
+
.map(([unit, tally]) => CommentDensityScanner.#warningOf(unit, tally));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
static #commentLinesOf({ file, sourceFile }: SourceFileInfo) {
|
|
45
|
+
const isRecipe = file.includes("/ui/Recipe/");
|
|
46
|
+
const lines = new Set<number>();
|
|
47
|
+
for (const comment of SourceComments.of(sourceFile)) {
|
|
48
|
+
if (CommentDensityScanner.#isExempt(comment)) continue;
|
|
49
|
+
const contentIdxs = comment.body.flatMap((text, idx) => (text ? [idx] : []));
|
|
50
|
+
//? A recipe's first JSDoc line is its description in the generated AGENTS.md recipe index.
|
|
51
|
+
const countedIdxs = isRecipe && comment.isJsDoc ? contentIdxs.slice(1) : contentIdxs;
|
|
52
|
+
for (const idx of countedIdxs) lines.add(comment.line + idx);
|
|
53
|
+
}
|
|
54
|
+
return [...lines].sort((a, b) => a - b);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static #isExempt({ raw, body }: SourceComment) {
|
|
58
|
+
const head = body.find((text) => text !== "") ?? "";
|
|
59
|
+
if (CommentDensityScanner.#directive.test(head)) return true;
|
|
60
|
+
return raw.startsWith("//") && CommentDensityScanner.#scaffoldMarkers.has(head.toLowerCase());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
static #isDense({ lineNum, commentLineNum }: CommentTally) {
|
|
64
|
+
return (
|
|
65
|
+
commentLineNum >= CommentDensityScanner.minCommentLineNum &&
|
|
66
|
+
commentLineNum > lineNum * CommentDensityScanner.maxShare
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
static #warningOf(unit: string, { lineNum, commentLineNum, files }: CommentTally): QualityWarning {
|
|
71
|
+
const [primary] = [...files].sort((a, b) => b.commentLineNum - a.commentLineNum);
|
|
72
|
+
const share = ((commentLineNum / lineNum) * 100).toFixed(2);
|
|
73
|
+
return {
|
|
74
|
+
rule: CommentDensityScanner.rule,
|
|
75
|
+
scope: "comment",
|
|
76
|
+
severity: "warning",
|
|
77
|
+
file: primary?.file ?? unit,
|
|
78
|
+
line: primary?.line,
|
|
79
|
+
message: `${unit} is ${share}% comments (${commentLineNum} of ${lineNum} non-blank lines); product lib/ and ui/ code stays under ${CommentDensityScanner.maxShare * 100}%.`,
|
|
80
|
+
...(files.length > 1 ? { locations: files.map(({ file, line }) => ({ file, line })) } : {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
static #unitOf(file: string) {
|
|
85
|
+
const segments = file.split("/");
|
|
86
|
+
if ((segments[0] !== "apps" && segments[0] !== "libs") || segments.length < 4) return null;
|
|
87
|
+
if (segments[2] === "ui") return segments.slice(0, 4).join("/");
|
|
88
|
+
if (segments[2] !== "lib") return null;
|
|
89
|
+
const depth = segments[3] === "__scalar" ? 5 : 4;
|
|
90
|
+
return segments.length > depth ? segments.slice(0, depth).join("/") : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
static #isMeasured({ file, sourceFile }: SourceFileInfo) {
|
|
94
|
+
if (/\.(test|spec)\.tsx?$/.test(file) || path.basename(file) === "guards.ts") return false;
|
|
95
|
+
return !sourceFile.statements.every(
|
|
96
|
+
(statement) =>
|
|
97
|
+
ts.isExportDeclaration(statement) ||
|
|
98
|
+
(ts.isExpressionStatement(statement) && ts.isStringLiteral(statement.expression)),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { ComponentSizeScanner } from "./componentSizeScanner";
|
|
4
|
+
|
|
5
|
+
const measure = (content: string) =>
|
|
6
|
+
new ComponentSizeScanner().measure(
|
|
7
|
+
ts.createSourceFile("Board.tsx", content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX),
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
const bodyOf = (lineNum: number) => Array.from({ length: lineNum }, (_, idx) => ` const v${idx} = ${idx};`).join("\n");
|
|
11
|
+
|
|
12
|
+
describe("ComponentSizeScanner", () => {
|
|
13
|
+
test("measures a function declaration and a const arrow from their first to their last line", () => {
|
|
14
|
+
const sizes = measure(
|
|
15
|
+
`import { cn } from "akanjs/client";\n\nexport function Board() {\n${bodyOf(3)}\n return null;\n}\n\nexport const Cell = () => {\n${bodyOf(10)}\n return null;\n};\n`,
|
|
16
|
+
);
|
|
17
|
+
expect(sizes).toEqual([
|
|
18
|
+
{ name: "Board", line: 3, lineCount: 6 },
|
|
19
|
+
{ name: "Cell", line: 10, lineCount: 13 },
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("measures a wrapped component with its wrapper", () => {
|
|
24
|
+
const sizes = measure(
|
|
25
|
+
`export const Canvas = memo(\n forwardRef((props, ref) => {\n${bodyOf(4)}\n return null;\n }),\n);\n`,
|
|
26
|
+
);
|
|
27
|
+
expect(sizes).toEqual([{ name: "Canvas", line: 1, lineCount: 9 }]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("skips a helper, a value, a bodiless overload and a nested component", () => {
|
|
31
|
+
const sizes = measure(
|
|
32
|
+
`const toLabel = () => "x";\nexport const Columns = 3;\nexport const Theme = { dark: true };\nexport function Overloaded(): null;\nexport const Outer = () => {\n const Inner = () => null;\n return <Inner />;\n};\n`,
|
|
33
|
+
);
|
|
34
|
+
expect(sizes.map(({ name }) => name)).toEqual(["Outer"]);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
|
|
3
|
+
export interface ComponentSize {
|
|
4
|
+
name: string;
|
|
5
|
+
line: number;
|
|
6
|
+
lineCount: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ComponentNode {
|
|
10
|
+
name: string;
|
|
11
|
+
node: ts.FunctionDeclaration | ts.VariableDeclaration;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
//? A hook is a component without markup: the same function-sized unit of screen code, so the same limits hold.
|
|
15
|
+
export class ComponentSizeScanner {
|
|
16
|
+
static readonly #componentName = /^(?:[A-Z]|use[A-Z])[A-Za-z0-9]*$/;
|
|
17
|
+
|
|
18
|
+
static componentsAndHooksOf(sourceFile: ts.SourceFile): ComponentNode[] {
|
|
19
|
+
return sourceFile.statements.flatMap((statement): ComponentNode[] => {
|
|
20
|
+
if (ts.isFunctionDeclaration(statement))
|
|
21
|
+
return statement.name && statement.body && ComponentSizeScanner.#componentName.test(statement.name.text)
|
|
22
|
+
? [{ name: statement.name.text, node: statement }]
|
|
23
|
+
: [];
|
|
24
|
+
if (!ts.isVariableStatement(statement)) return [];
|
|
25
|
+
return statement.declarationList.declarations.flatMap((declaration) =>
|
|
26
|
+
ts.isIdentifier(declaration.name) &&
|
|
27
|
+
ComponentSizeScanner.#componentName.test(declaration.name.text) &&
|
|
28
|
+
declaration.initializer &&
|
|
29
|
+
ComponentSizeScanner.#holdsFunction(declaration.initializer)
|
|
30
|
+
? [{ name: declaration.name.text, node: declaration }]
|
|
31
|
+
: [],
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
measure(sourceFile: ts.SourceFile): ComponentSize[] {
|
|
37
|
+
return ComponentSizeScanner.componentsAndHooksOf(sourceFile).map(({ name, node }) => {
|
|
38
|
+
const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
39
|
+
const endLine = sourceFile.getLineAndCharacterOfPosition(node.getEnd()).line + 1;
|
|
40
|
+
return { name, line, lineCount: endLine - line + 1 };
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
//? A wrapped component (`memo(() => …)`, `forwardRef(…)`) is still one component, measured with its wrapper.
|
|
45
|
+
static #holdsFunction(expression: ts.Expression): boolean {
|
|
46
|
+
let current = expression;
|
|
47
|
+
while (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isSatisfiesExpression(current))
|
|
48
|
+
current = current.expression;
|
|
49
|
+
if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) return true;
|
|
50
|
+
return ts.isCallExpression(current) && current.arguments.some((arg) => ComponentSizeScanner.#holdsFunction(arg));
|
|
51
|
+
}
|
|
52
|
+
}
|