@akanjs/devkit 3.1.1-rc.16 → 3.1.1-rc.18

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.
Files changed (38) hide show
  1. package/agentAssets.test.ts +65 -0
  2. package/agentAssets.ts +166 -0
  3. package/agentsIndex.ts +4 -4
  4. package/akanContext.ts +44 -2
  5. package/applicationBuildRunner.ts +1 -0
  6. package/codeAgent/agent/akanSystemPrompt.ts +1 -1
  7. package/codeAgent/feedback/VerifyFeedback.ts +3 -4
  8. package/codeAgent/skills/akan-agent/SKILL.md +36 -0
  9. package/codeAgent/skills/akan-debug/SKILL.md +3 -3
  10. package/codeAgent/skills/akan-deps/SKILL.md +1 -2
  11. package/codeAgent/skills/akan-mcp/SKILL.md +40 -0
  12. package/codeAgent/skills/akan-page/SKILL.md +44 -0
  13. package/codeAgent/skills/akan-query/SKILL.md +38 -0
  14. package/codeAgent/skills/akan-runtime/SKILL.md +40 -0
  15. package/codeAgent/skills/akan-scaffold/SKILL.md +16 -6
  16. package/codeAgent/skills/akan-transport/SKILL.md +40 -0
  17. package/codeAgent/skills/akan-validate/SKILL.md +13 -11
  18. package/codeAgent/tools/AkanCli.ts +2 -1
  19. package/codeAgent/tools/AkanEditScope.ts +8 -3
  20. package/codeAgent/tools/AkanToolPack.ts +4 -10
  21. package/codeAgent/tools/AkanVerifier.ts +13 -31
  22. package/codeAgent/tools/akanCodePlugins.test.ts +12 -30
  23. package/conventionSet.test.ts +73 -0
  24. package/conventionSet.ts +67 -0
  25. package/executors.test.ts +0 -4
  26. package/maintainabilityReport.ts +11 -0
  27. package/package.json +2 -2
  28. package/prompter.ts +7 -0
  29. package/typeChecker.ts +32 -0
  30. package/typecheck/projectTypecheck.test.ts +18 -0
  31. package/typecheck/projectTypecheck.ts +29 -5
  32. package/typecheck/typecheck.proc.ts +6 -0
  33. package/typecheck/typecheckFailure.ts +13 -0
  34. package/workflow/artifacts.ts +10 -1
  35. package/workflow/render.ts +4 -3
  36. package/workflow/source.test.ts +55 -0
  37. package/workflow/source.ts +29 -10
  38. package/workflow/types.ts +1 -1
@@ -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
- `akan_verify` runs sync → lint → typecheck → `quality ssr` over everything the working tree has changed, in
11
- the right order, and reports only what failed. **Call it after editing, before you say you are done.** It
12
- also runs itself at the end of a turn that changed source, so a failure comes back to you either way.
13
-
14
- The same chain by hand, for the app you are editing:
15
-
16
- - `akan sync <app>` — after adding, renaming, or deleting any file. Regenerates the barrels.
17
- - `akan lint <app>` — fast; run after every edit. Prints up to 200 diagnostics.
18
- - `akan typecheck <app>` — the client/server boundary violations land here, not in lint.
19
- - `akan quality ssr` — only when you wrote or changed a `.tsx`. Did the server share hold?
20
- - `akan build <app>` — the full production build; slower, and only when the build itself is in question.
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
- env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" },
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) => file.split("/")[1] ?? "")
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 akan validation chain (sync, lint, typecheck, and quality ssr for .tsx changes) over everything the working tree has changed, and report what failed. Call this after editing before you finish.",
70
- promptSnippet: "akan_verify: run sync/lint/typecheck/quality over the working tree's changes",
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 scope = await AkanEditScope.since(this.#options.cwd, new Set());
74
- const report = await verifier.verify(scope);
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
- steps: AkanCliResult[];
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(scope: AkanEditScopeResult): Promise<AkanVerifyReport> {
18
- const steps: AkanCliResult[] = [];
19
- const targets = [...scope.apps, ...scope.libs];
20
- // Order matters: a stale barrel reads to typecheck as a missing symbol, and lint moves the positions it reports.
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
- /** What a model needs to act: which step failed and its tail — never the whole output, which rides every later turn. */
29
- static summarize(report: AkanVerifyReport, { tailLines = 40 }: { tailLines?: number } = {}) {
30
- if (!report.steps.length) return "No akan target was touched, so there was nothing to verify.";
31
- if (report.ok) return `Verification passed: ${report.steps.map((step) => step.command).join(", ")}.`;
32
- const failed = report.steps.filter((step) => !step.ok);
33
- const passed = report.steps.filter((step) => step.ok).map((step) => step.command);
34
- const blocks = failed.map((step) => `### ${step.command} (exit ${step.exitCode})\n${tail(step.output, tailLines)}`);
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
- const scope = { paths: ["apps/a/x.ts"], apps: ["a"], libs: [], needsSync: false, touchesTsx: false };
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("names the failing step and keeps only its tail", () => {
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 10\n");
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,73 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import fsPromise from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { ConventionSet } from "./conventionSet";
5
+ import { MaintainabilityContract } from "./maintainabilityContract";
6
+ import { Prompter } from "./prompter";
7
+
8
+ describe("ConventionSet", () => {
9
+ test("expands every brace list, so a comma-separated glob field can carry it", () => {
10
+ expect(ConventionSet.expand("{apps,libs}/**/*.{ts,tsx}")).toEqual([
11
+ "apps/**/*.ts",
12
+ "apps/**/*.tsx",
13
+ "libs/**/*.ts",
14
+ "libs/**/*.tsx",
15
+ ]);
16
+ expect(ConventionSet.expand("**/.env*")).toEqual(["**/.env*"]);
17
+ });
18
+
19
+ test("reads a rule's description and paths from its frontmatter, and refuses one without", () => {
20
+ const rule = ConventionSet.parseRule(
21
+ "page",
22
+ '---\ndescription: Route files.\npaths:\n - "{apps,libs}/**/page/**"\n---\n\n# Pages\n',
23
+ );
24
+ expect(rule).toEqual({
25
+ name: "page",
26
+ description: "Route files.",
27
+ paths: ["{apps,libs}/**/page/**"],
28
+ body: "# Pages",
29
+ });
30
+ expect(() => ConventionSet.parseRule("bare", "# Pages\n")).toThrow("no frontmatter");
31
+ expect(() => ConventionSet.parseRule("empty", "---\ndescription: x\n---\n")).toThrow("description and paths");
32
+ });
33
+
34
+ test("nests each rule under the core when composed, leaving fenced code alone", () => {
35
+ const set = new ConventionSet("## Core", [
36
+ { name: "a", description: "d", paths: ["x"], body: "# A\n\n## Part\n\n```sh\n# a comment\n```" },
37
+ ]);
38
+ expect(set.instruction).toBe("## Core\n\n## A\n\n### Part\n\n```sh\n# a comment\n```\n");
39
+ });
40
+
41
+ test("the shipped conventions guideline is the core followed by every rule", async () => {
42
+ const set = await Prompter.getConventionSet();
43
+ const instruction = await Prompter.getInstruction("conventions");
44
+ expect(set.rules.length).toBeGreaterThanOrEqual(10);
45
+ expect(instruction.startsWith(set.core)).toBe(true);
46
+ for (const rule of set.rules) {
47
+ expect(rule.body).toStartWith("# ");
48
+ expect(instruction).toContain(`\n#${rule.body.split("\n")[0]}\n`);
49
+ }
50
+ });
51
+
52
+ test("names only checks that exist, and names every lint plugin and maintainability gate somewhere", async () => {
53
+ const set = await Prompter.getConventionSet();
54
+ const named = new Set(
55
+ [set.core, ...set.rules.map((rule) => rule.body)].flatMap((text) =>
56
+ [...text.matchAll(/`([^`\s]+)`/g)].map((match) => match[1] ?? ""),
57
+ ),
58
+ );
59
+ const plugins = (await fsPromise.readdir(path.join(import.meta.dir, "lint")))
60
+ .filter((file) => file.endsWith(".grit"))
61
+ .map((file) => file.slice(0, -".grit".length));
62
+ const gates: readonly string[] = MaintainabilityContract.ruleIds;
63
+ const gateAreas = new Set(gates.map((id) => id.split(".").slice(0, 2).join(".")));
64
+ const isPluginName = (name: string) => /^non?-[a-z]+(-[a-z]+)+$/.test(name);
65
+ const isGateName = (name: string) =>
66
+ gateAreas.has(name.split(".").slice(0, 2).join(".")) && !name.endsWith(".json");
67
+
68
+ expect([...named].filter((name) => isPluginName(name) && !plugins.includes(name))).toEqual([]);
69
+ expect([...named].filter((name) => isGateName(name) && !gates.includes(name))).toEqual([]);
70
+ expect(plugins.filter((plugin) => !named.has(plugin))).toEqual([]);
71
+ expect(gates.filter((gate) => !named.has(gate))).toEqual([]);
72
+ });
73
+ });
@@ -0,0 +1,67 @@
1
+ import fsPromise from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export interface ConventionRule {
5
+ name: string;
6
+ description: string;
7
+ paths: string[];
8
+ body: string;
9
+ }
10
+
11
+ export class ConventionSet {
12
+ constructor(
13
+ readonly core: string,
14
+ readonly rules: ConventionRule[],
15
+ ) {}
16
+
17
+ static async load(dir: string) {
18
+ const core = await fsPromise.readFile(path.join(dir, "core.md"), "utf-8");
19
+ const files = (await fsPromise.readdir(path.join(dir, "rules"))).filter((file) => file.endsWith(".md")).sort();
20
+ const rules = await Promise.all(
21
+ files.map(async (file) =>
22
+ ConventionSet.parseRule(file.slice(0, -3), await fsPromise.readFile(path.join(dir, "rules", file), "utf-8")),
23
+ ),
24
+ );
25
+ return new ConventionSet(core.trim(), rules);
26
+ }
27
+
28
+ static parseRule(name: string, text: string): ConventionRule {
29
+ const match: RegExpExecArray | null = /^---\n([\s\S]*?)\n---\n/.exec(text);
30
+ if (!match) throw new Error(`Convention rule ${name} has no frontmatter`);
31
+ const front = match[1] ?? "";
32
+ const described: RegExpExecArray | null = /^description:\s*(.+)$/m.exec(front);
33
+ const description = described?.[1]?.trim() ?? "";
34
+ const paths = [...front.matchAll(/^\s+-\s+"([^"]+)"$/gm)].map((entry) => entry[1] ?? "");
35
+ if (!description || paths.length === 0) throw new Error(`Convention rule ${name} needs a description and paths`);
36
+ return { name, description, paths, body: text.slice(match[0].length).trim() };
37
+ }
38
+
39
+ //? Cursor splits `globs:` on commas, so a `{a,b}` brace list has to be expanded before it is written there.
40
+ static expand(glob: string): string[] {
41
+ const open = glob.indexOf("{");
42
+ if (open === -1) return [glob];
43
+ const close = glob.indexOf("}", open);
44
+ if (close === -1) return [glob];
45
+ const head = glob.slice(0, open);
46
+ const tail = glob.slice(close + 1);
47
+ return glob
48
+ .slice(open + 1, close)
49
+ .split(",")
50
+ .flatMap((choice) => ConventionSet.expand(`${head}${choice}${tail}`));
51
+ }
52
+
53
+ static demote(markdown: string) {
54
+ let fenced = false;
55
+ return markdown
56
+ .split("\n")
57
+ .map((line) => {
58
+ if (line.startsWith("```")) fenced = !fenced;
59
+ return !fenced && /^#{1,5} /.test(line) ? `#${line}` : line;
60
+ })
61
+ .join("\n");
62
+ }
63
+
64
+ get instruction() {
65
+ return `${[this.core, ...this.rules.map((rule) => ConventionSet.demote(rule.body))].join("\n\n")}\n`;
66
+ }
67
+ }
package/executors.test.ts CHANGED
@@ -275,10 +275,6 @@ describe("Executor filesystem helpers", () => {
275
275
  "Akan workspace agent guide",
276
276
  );
277
277
  expect(await readFile(path.join(root, "workspace/AGENTS.md"), "utf8")).toContain("sample Agent Guide");
278
- expect(await readFile(path.join(root, "workspace/docs/AI-DEVELOPMENT.md"), "utf8")).toContain(
279
- "AI Development Guide",
280
- );
281
- expect(await readFile(path.join(root, "workspace/docs/GENERATED.md"), "utf8")).toContain("Generated Akan Files");
282
278
  // Rules live in the package's base config, so a framework release reaches an existing workspace on `bun update`.
283
279
  expect(await readFile(path.join(root, "workspace/biome.json"), "utf8")).toContain(
284
280
  '"extends": ["@akanjs/devkit/biome.base.json"]',
@@ -95,6 +95,17 @@ export class MaintainabilityReport {
95
95
  ].join("\n");
96
96
  }
97
97
 
98
+ static entriesOf(sysPath: string, failures: MaintainabilityFailure[], commands: MaintainabilityCommands = {}) {
99
+ return failures.map((failure) => ({
100
+ file: `${sysPath}/${failure.file}`,
101
+ line: failure.line,
102
+ rule: failure.rule,
103
+ problem: MaintainabilityReport.#problemOf(failure),
104
+ fix: MaintainabilityReport.#fixes[MaintainabilityReport.#fixKeyOf(failure)],
105
+ path: commands[failure.rule]?.[0] ?? null,
106
+ }));
107
+ }
108
+
98
109
  static #problemOf(failure: MaintainabilityFailure) {
99
110
  if (failure.kind === "baselineGrowth") {
100
111
  const entry = `entry "${failure.key}" under ${failure.debtRule}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.1.1-rc.16",
3
+ "version": "3.1.1-rc.18",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -48,7 +48,7 @@
48
48
  "@earendil-works/pi-tui": "1.3.0",
49
49
  "@inquirer/prompts": "^8.7.3",
50
50
  "@tailwindcss/node": "^4.3.3",
51
- "akanjs": "3.1.1-rc.16",
51
+ "akanjs": "3.1.1-rc.18",
52
52
  "chalk": "^6.0.1",
53
53
  "commander": "^15.0.0",
54
54
  "dayjs": "^1.11.23",
package/prompter.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import fsPromise from "node:fs/promises";
2
2
  import { input, select } from "@inquirer/prompts";
3
3
 
4
+ import { ConventionSet } from "./conventionSet";
4
5
  import { getDirname } from "./fileSys";
5
6
 
6
7
  interface FileUpdateRequestProps {
@@ -40,8 +41,14 @@ export class Prompter {
40
41
  if (!guideNames.includes(guideName))
41
42
  throw new Error(`Unknown guideline "${guideName}". Available: ${guideNames.join(", ")}`);
42
43
  const guidelineRoot = await Prompter.#getGuidelineRoot();
44
+ const composed = `${guidelineRoot}/${guideName}/core.md`;
45
+ if (await Bun.file(composed).exists())
46
+ return (await ConventionSet.load(`${guidelineRoot}/${guideName}`)).instruction;
43
47
  return await fsPromise.readFile(`${guidelineRoot}/${guideName}/${guideName}.instruction.md`, "utf-8");
44
48
  }
49
+ static async getConventionSet() {
50
+ return await ConventionSet.load(`${await Prompter.#getGuidelineRoot()}/conventions`);
51
+ }
45
52
  static async getUpdateRequest(guideName: string) {
46
53
  return await input({
47
54
  message: `What do you want to update in ${guideName}?`,
package/typeChecker.ts CHANGED
@@ -13,6 +13,16 @@ export interface ProjectTypecheckResult {
13
13
  message: string;
14
14
  }
15
15
 
16
+ export interface TypecheckRecord {
17
+ /** Relative to the workspace root, `/`-separated; `null` for a diagnostic tied to no file. */
18
+ file: string | null;
19
+ line: number;
20
+ col: number;
21
+ code: string;
22
+ severity: "error" | "warning";
23
+ message: string;
24
+ }
25
+
16
26
  export class TypeChecker {
17
27
  readonly configPath: string;
18
28
  readonly configFile: { config?: any; error?: ts.Diagnostic };
@@ -198,6 +208,28 @@ export class TypeChecker {
198
208
  return parsedConfig;
199
209
  }
200
210
 
211
+ static toRecords(diagnostics: ts.Diagnostic[], root: string): TypecheckRecord[] {
212
+ return diagnostics
213
+ .filter(
214
+ (diagnostic) =>
215
+ diagnostic.category === ts.DiagnosticCategory.Error || diagnostic.category === ts.DiagnosticCategory.Warning,
216
+ )
217
+ .map((diagnostic) => {
218
+ const position =
219
+ diagnostic.file && diagnostic.start !== undefined
220
+ ? diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start)
221
+ : null;
222
+ return {
223
+ file: diagnostic.file ? path.relative(root, diagnostic.file.fileName).split(path.sep).join("/") : null,
224
+ line: position ? position.line + 1 : 0,
225
+ col: position ? position.character + 1 : 0,
226
+ code: `TS${diagnostic.code}`,
227
+ severity: diagnostic.category === ts.DiagnosticCategory.Error ? "error" : "warning",
228
+ message: ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
229
+ };
230
+ });
231
+ }
232
+
201
233
  static formatDiagnosticMessages(diagnostics: ts.Diagnostic[]): string {
202
234
  return ts.formatDiagnosticsWithColorAndContext(diagnostics, {
203
235
  getCanonicalFileName: (fileName) => fileName,
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import path from "node:path";
3
3
  import { tempDirs, writeText as write } from "../testHelpers";
4
4
  import { ProjectTypecheck } from "./projectTypecheck";
5
+ import { TypecheckFailure } from "./typecheckFailure";
5
6
 
6
7
  const makeTempRoot = tempDirs("akan-devkit-project-typecheck-");
7
8
 
@@ -29,4 +30,21 @@ describe("ProjectTypecheck", () => {
29
30
  expect((error as Error).message).toContain("lib/user/user.signal.test.ts");
30
31
  expect((error as Error).message).not.toContain("old.test.ts");
31
32
  }, 60_000);
33
+
34
+ test("hands back one record per diagnostic when asked for records", async () => {
35
+ const { root, dir } = await project();
36
+ const error = await new ProjectTypecheck(dir, root, { ...process.env })
37
+ .run(["../../server.ts"], { tests: true, report: "records" })
38
+ .catch((e: unknown) => e);
39
+ expect(error).toBeInstanceOf(TypecheckFailure);
40
+ const [record, ...rest] = (error as TypecheckFailure).records;
41
+ expect(rest).toEqual([]);
42
+ expect(record).toMatchObject({
43
+ file: "libs/demo/lib/user/user.signal.test.ts",
44
+ line: 1,
45
+ col: 14,
46
+ code: "TS2322",
47
+ severity: "error",
48
+ });
49
+ }, 60_000);
32
50
  });
@@ -1,11 +1,15 @@
1
1
  import { mkdir, rm } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import type { TypecheckRecord } from "../typeChecker";
4
+ import { TypecheckFailure } from "./typecheckFailure";
3
5
 
4
6
  export interface ProjectTypecheckOptions {
5
7
  clean?: boolean;
6
8
  incremental?: boolean;
7
9
  /** The project's own `*.test.ts` / `*.spec.ts` as well. */
8
10
  tests?: boolean;
11
+ /** `records` fails with a `TypecheckFailure` carrying one record per diagnostic instead of the printed report. */
12
+ report?: "pretty" | "records";
9
13
  }
10
14
 
11
15
  //* tsc checks what the entries import, and nothing imports a test, so a test is checked only when it is included by
@@ -25,7 +29,10 @@ export class ProjectTypecheck {
25
29
  }
26
30
 
27
31
  /** `entries`: relative to the typecheck folder (`../../server.ts`). */
28
- async run(entries: string[], { clean = false, incremental = true, tests = false }: ProjectTypecheckOptions = {}) {
32
+ async run(
33
+ entries: string[],
34
+ { clean = false, incremental = true, tests = false, report = "pretty" }: ProjectTypecheckOptions = {},
35
+ ) {
29
36
  const name = tests ? "tsconfig.tests" : "tsconfig";
30
37
  await mkdir(this.typecheckDir, { recursive: true });
31
38
  const tsconfig = {
@@ -38,14 +45,19 @@ export class ProjectTypecheck {
38
45
  const tsconfigPath = path.join(this.typecheckDir, `${name}.json`);
39
46
  await Bun.write(tsconfigPath, `${JSON.stringify(tsconfig, null, 2)}\n`);
40
47
  if (clean) await rm(path.join(this.typecheckDir, `${name}.tsbuildinfo`), { force: true });
41
- await this.#check(tsconfigPath);
48
+ await this.check(tsconfigPath, report);
42
49
  return tsconfigPath;
43
50
  }
44
51
 
45
- async #check(tsconfigPath: string) {
52
+ async check(tsconfigPath: string, report: "pretty" | "records" = "pretty") {
46
53
  const proc = Bun.spawn([process.execPath, await ProjectTypecheck.workerEntry(this.workspaceRoot)], {
47
54
  cwd: this.workspaceRoot,
48
- env: { ...this.env, AKAN_COMMAND_TYPE: "typecheck", AKAN_TYPECHECK_TSCONFIG: tsconfigPath },
55
+ env: {
56
+ ...this.env,
57
+ AKAN_COMMAND_TYPE: "typecheck",
58
+ AKAN_TYPECHECK_TSCONFIG: tsconfigPath,
59
+ ...(report === "records" ? { AKAN_TYPECHECK_FORMAT: "json", AKAN_TYPECHECK_ROOT: this.workspaceRoot } : {}),
60
+ },
49
61
  stdout: "pipe",
50
62
  stderr: "pipe",
51
63
  });
@@ -54,7 +66,19 @@ export class ProjectTypecheck {
54
66
  new Response(proc.stderr).text(),
55
67
  proc.exited,
56
68
  ]);
57
- if (exitCode !== 0) throw new Error((stderr || stdout).trim() || `Typecheck failed with exit code ${exitCode}`);
69
+ if (exitCode === 0) return;
70
+ const records = report === "records" ? ProjectTypecheck.#parseRecords(stdout) : null;
71
+ if (records?.length) throw new TypecheckFailure(records);
72
+ throw new Error((stderr || stdout).trim() || `Typecheck failed with exit code ${exitCode}`);
73
+ }
74
+
75
+ static #parseRecords(stdout: string): TypecheckRecord[] | null {
76
+ try {
77
+ const parsed: unknown = JSON.parse(stdout);
78
+ return Array.isArray(parsed) ? (parsed as TypecheckRecord[]) : null;
79
+ } catch {
80
+ return null;
81
+ }
58
82
  }
59
83
 
60
84
  //? The workspace's own devkit first, then an installed one, then the entry a bundle puts beside its chunks.