@akanjs/devkit 3.1.1-rc.9 → 3.1.1
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 +30 -0
- package/akanConfig/akanConfig.ts +10 -0
- package/akanConfig/productionLockfile.test.ts +31 -0
- package/akanConfig/productionLockfile.ts +2 -0
- package/akanContext.ts +129 -4
- package/applicationBuildRunner.test.ts +17 -0
- package/applicationBuildRunner.ts +3 -1
- package/biome.base.json +4 -1
- package/codeAgent/agent/CodeAgent.ts +4 -0
- package/codeAgent/agent/SubagentPool.ts +5 -0
- package/codeAgent/agent/akanSystemPrompt.ts +1 -1
- package/codeAgent/agent/codeAgent.test.ts +41 -2
- 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 +36 -0
- package/codeAgent/skills/akan-code/SKILL.md +1 -0
- package/codeAgent/skills/akan-debug/SKILL.md +40 -6
- 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/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/akanCodePlugins.test.ts +60 -58
- 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/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/executors.test.ts +0 -4
- package/executors.ts +18 -2
- package/index.ts +1 -0
- package/integration/devStability.integration.test.ts +9 -5
- 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/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/package.json +3 -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/signalMutationIndex.test.ts +15 -0
- package/signalMutationIndex.ts +1 -1
- 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/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 +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
- package/codeAgent/feedback/PreviewView.ts +0 -95
- /package/{codeAgent/feedback → preview}/previewChrome.test.ts +0 -0
package/conventionSet.ts
ADDED
|
@@ -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
|
+
}
|
|
@@ -61,7 +61,12 @@ describe.skipIf(!CsrE2eHarness.enabled)("SSR dev registry (minimal)", () => {
|
|
|
61
61
|
let ssr: CsrE2eHarness;
|
|
62
62
|
|
|
63
63
|
beforeAll(async () => {
|
|
64
|
-
ssr = await CsrE2eHarness.start({
|
|
64
|
+
ssr = await CsrE2eHarness.start({
|
|
65
|
+
app: "minimal",
|
|
66
|
+
port,
|
|
67
|
+
// The server-log assertions match verbose-level lines, which a workspace `.env` may or may not turn on.
|
|
68
|
+
env: { AKAN_DEV_SSR_ARM_DELAY_MS: "3000", AKAN_PUBLIC_LOG_LEVEL: "verbose" },
|
|
69
|
+
});
|
|
65
70
|
}, 240_000);
|
|
66
71
|
|
|
67
72
|
afterAll(async () => {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { DeprecatedCommentScanner } from "./deprecatedCommentScanner";
|
|
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(file, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const scan = (file: string, lines: string[]) =>
|
|
14
|
+
new DeprecatedCommentScanner().scan([fileOf(file, `${lines.join("\n")}\n`)]);
|
|
15
|
+
|
|
16
|
+
describe("DeprecatedCommentScanner", () => {
|
|
17
|
+
test("flags a trailing, a leading and a block deprecated comment", () => {
|
|
18
|
+
const warnings = scan("apps/demo/lib/court/court.constant.ts", [
|
|
19
|
+
"export class CourtInput extends via((field) => ({",
|
|
20
|
+
" legacyVenue: field(String).optional(), // deprecated",
|
|
21
|
+
" // Deprecated: use courtId",
|
|
22
|
+
" legacyCourt: field(String).optional(),",
|
|
23
|
+
" /* DEPRECATED */ oldName: field(String).optional(),",
|
|
24
|
+
" /** deprecated, read venueId */ oldVenue: field(String).optional(),",
|
|
25
|
+
"})) {}",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
expect(warnings.map((warning) => warning.line)).toEqual([2, 3, 5, 6]);
|
|
29
|
+
expect(warnings[0]).toMatchObject({ rule: "akan.comment.deprecated-marker", scope: "comment" });
|
|
30
|
+
expect(warnings[0]?.message).toContain('"// deprecated"');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("leaves a JSDoc @deprecated tag and prose that only mentions deprecation", () => {
|
|
34
|
+
expect(
|
|
35
|
+
scan("libs/demo/ui/field.type.ts", [
|
|
36
|
+
"export interface FieldProps {",
|
|
37
|
+
" /** @deprecated Ignored: the field renders `value`. */",
|
|
38
|
+
" defaultValue?: string;",
|
|
39
|
+
" /**",
|
|
40
|
+
" * Old name.",
|
|
41
|
+
" * @deprecated use `value`",
|
|
42
|
+
" */",
|
|
43
|
+
" initial?: string;",
|
|
44
|
+
"}",
|
|
45
|
+
"// Replaces the deprecated upstream plugin.",
|
|
46
|
+
"export const notice = '// deprecated';",
|
|
47
|
+
]),
|
|
48
|
+
).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("stays out of tests", () => {
|
|
52
|
+
expect(scan("apps/demo/lib/court/court.signal.test.ts", ["const venue = 1; // deprecated"])).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
|
|
2
|
+
import { type SourceComment, SourceComments } from "./sourceComments";
|
|
3
|
+
|
|
4
|
+
export class DeprecatedCommentScanner {
|
|
5
|
+
static readonly rule = "akan.comment.deprecated-marker";
|
|
6
|
+
static #marker = /^[!?*\s]*(?:(?:TODO|FIXME|XXX|NOTE)\b\s*:?\s*)?@?deprecated\b/i;
|
|
7
|
+
|
|
8
|
+
scan(sourceFiles: SourceFileInfo[]): QualityWarning[] {
|
|
9
|
+
return sourceFiles
|
|
10
|
+
.filter(({ file }) => /^(apps|libs)\//.test(file) && !/\.(test|spec)\.tsx?$/.test(file))
|
|
11
|
+
.flatMap(({ file, sourceFile }) =>
|
|
12
|
+
SourceComments.of(sourceFile)
|
|
13
|
+
.filter((comment) => DeprecatedCommentScanner.#isLooseMarker(comment))
|
|
14
|
+
.map((comment) => ({
|
|
15
|
+
rule: DeprecatedCommentScanner.rule,
|
|
16
|
+
scope: "comment" as const,
|
|
17
|
+
severity: "warning" as const,
|
|
18
|
+
file,
|
|
19
|
+
line: comment.line,
|
|
20
|
+
message: `"${comment.raw.split(/\r?\n/)[0]?.trim().slice(0, 80)}" marks nothing at the use sites; editors strike through only a JSDoc @deprecated tag.`,
|
|
21
|
+
})),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
static #isLooseMarker({ isJsDoc, body }: SourceComment) {
|
|
26
|
+
const text = body.filter((line) => line !== "").join(" ");
|
|
27
|
+
if (isJsDoc && /(^|\s)@deprecated\b/.test(text)) return false;
|
|
28
|
+
return DeprecatedCommentScanner.#marker.test(text);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { EndpointReach } from "./endpointReach";
|
|
4
|
+
|
|
5
|
+
const fileOf = (file: string, content: string) => ({
|
|
6
|
+
file,
|
|
7
|
+
sourceFile: ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe("EndpointReach", () => {
|
|
11
|
+
test("follows an endpoint or a slice through its service's own and other services' methods, not a job", () => {
|
|
12
|
+
const reach = new EndpointReach(
|
|
13
|
+
[
|
|
14
|
+
fileOf(
|
|
15
|
+
"apps/demo/lib/court/court.signal.ts",
|
|
16
|
+
`export class CourtInternal extends internal(srv.court, ({ cron }) => ({
|
|
17
|
+
sweep: cron("0 * * * *").exec(function () { return this.courtService.sweep(); }),
|
|
18
|
+
})) {}
|
|
19
|
+
export class CourtSlice extends slice(srv.court, { guards: { root: Admin } }, (init) => ({
|
|
20
|
+
inOrg: init().exec(function (orgId) { return this.courtService.queryInOrg(orgId); }),
|
|
21
|
+
})) {}
|
|
22
|
+
export class CourtEndpoint extends endpoint(srv.court, ({ mutation }) => ({
|
|
23
|
+
assign: mutation(Boolean).exec(function (id) { return this.courtService.assign(id); }),
|
|
24
|
+
})) {}
|
|
25
|
+
`,
|
|
26
|
+
),
|
|
27
|
+
],
|
|
28
|
+
[
|
|
29
|
+
fileOf(
|
|
30
|
+
"apps/demo/lib/court/court.service.ts",
|
|
31
|
+
`export class CourtService extends serve(db.court, () => ({})) {
|
|
32
|
+
queryInOrg(orgId: string) { return { orgId }; }
|
|
33
|
+
async assign(id: string) { return await this.#pick(id); }
|
|
34
|
+
async #pick(id: string) { return await this.pcService.claim(id); }
|
|
35
|
+
async sweep() { return await this.#pick("all"); }
|
|
36
|
+
}
|
|
37
|
+
`,
|
|
38
|
+
),
|
|
39
|
+
fileOf(
|
|
40
|
+
"libs/shared/lib/_pc/pc.service.ts",
|
|
41
|
+
`export class PcService extends serve("pc", () => ({})) {
|
|
42
|
+
async claim(id: string) { return id; }
|
|
43
|
+
async release(id: string) { return id; }
|
|
44
|
+
}
|
|
45
|
+
`,
|
|
46
|
+
),
|
|
47
|
+
],
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
expect(EndpointReach.serviceOf("libs/shared/lib/_pc/pc.service.ts")).toBe("pc");
|
|
51
|
+
expect(
|
|
52
|
+
["court.queryInOrg", "court.assign", "court.#pick", "pc.claim", "court.sweep", "pc.release"].map((key) => {
|
|
53
|
+
const [service = "", method = ""] = key.split(".");
|
|
54
|
+
return reach.reaches(service, method);
|
|
55
|
+
}),
|
|
56
|
+
).toEqual([true, true, true, true, false, false]);
|
|
57
|
+
});
|
|
58
|
+
});
|
package/endpointReach.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import type { SourceFileInfo } from "./qualityScanner";
|
|
4
|
+
|
|
5
|
+
type ServiceMethod = [service: string, method: string];
|
|
6
|
+
|
|
7
|
+
//? Only `endpoint(…)` and `slice(…)` answer a caller who waits; an `internal(…)` job (cron, interval, queue) works
|
|
8
|
+
//? per item on purpose, so that one failing item leaves the rest done.
|
|
9
|
+
export class EndpointReach {
|
|
10
|
+
static readonly #requestBuilders = new Set(["endpoint", "slice"]);
|
|
11
|
+
readonly #reached = new Set<string>();
|
|
12
|
+
|
|
13
|
+
constructor(
|
|
14
|
+
signalFiles: Pick<SourceFileInfo, "sourceFile">[],
|
|
15
|
+
serviceFiles: Pick<SourceFileInfo, "file" | "sourceFile">[],
|
|
16
|
+
) {
|
|
17
|
+
const methodsByService = new Map<string, Map<string, ts.MethodDeclaration>>();
|
|
18
|
+
for (const { file, sourceFile } of serviceFiles) {
|
|
19
|
+
const methods = methodsByService.get(EndpointReach.serviceOf(file)) ?? new Map<string, ts.MethodDeclaration>();
|
|
20
|
+
for (const cls of sourceFile.statements.filter(ts.isClassDeclaration))
|
|
21
|
+
for (const member of cls.members)
|
|
22
|
+
if (
|
|
23
|
+
ts.isMethodDeclaration(member) &&
|
|
24
|
+
member.body &&
|
|
25
|
+
(ts.isIdentifier(member.name) || ts.isPrivateIdentifier(member.name))
|
|
26
|
+
)
|
|
27
|
+
methods.set(member.name.text, member);
|
|
28
|
+
methodsByService.set(EndpointReach.serviceOf(file), methods);
|
|
29
|
+
}
|
|
30
|
+
const pending: ServiceMethod[] = [];
|
|
31
|
+
const reach = ([service, method]: ServiceMethod) => {
|
|
32
|
+
const key = `${service}.${method}`;
|
|
33
|
+
if (this.#reached.has(key) || !methodsByService.get(service)?.has(method)) return;
|
|
34
|
+
this.#reached.add(key);
|
|
35
|
+
pending.push([service, method]);
|
|
36
|
+
};
|
|
37
|
+
for (const { sourceFile } of signalFiles)
|
|
38
|
+
for (const cls of sourceFile.statements.filter(ts.isClassDeclaration)) {
|
|
39
|
+
const base = cls.heritageClauses?.[0]?.types[0]?.expression;
|
|
40
|
+
if (
|
|
41
|
+
base &&
|
|
42
|
+
ts.isCallExpression(base) &&
|
|
43
|
+
ts.isIdentifier(base.expression) &&
|
|
44
|
+
EndpointReach.#requestBuilders.has(base.expression.text)
|
|
45
|
+
)
|
|
46
|
+
EndpointReach.#serviceCallsIn(base, null).forEach(reach);
|
|
47
|
+
}
|
|
48
|
+
for (let next = pending.pop(); next; next = pending.pop()) {
|
|
49
|
+
const [service, method] = next;
|
|
50
|
+
const declaration = methodsByService.get(service)?.get(method);
|
|
51
|
+
if (declaration) EndpointReach.#serviceCallsIn(declaration, service).forEach(reach);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
static serviceOf(file: string) {
|
|
56
|
+
return path.posix.basename(file).replace(/\.service\.ts$/, "");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
reaches(service: string, method: string) {
|
|
60
|
+
return this.#reached.has(`${service}.${method}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
static #serviceCallsIn(node: ts.Node, ownService: string | null) {
|
|
64
|
+
const calls: ServiceMethod[] = [];
|
|
65
|
+
const visit = (child: ts.Node) => {
|
|
66
|
+
if (ts.isCallExpression(child) && ts.isPropertyAccessExpression(child.expression)) {
|
|
67
|
+
const { expression: owner, name } = child.expression;
|
|
68
|
+
if (owner.kind === ts.SyntaxKind.ThisKeyword && ownService) calls.push([ownService, name.text]);
|
|
69
|
+
else if (ts.isPropertyAccessExpression(owner) && owner.expression.kind === ts.SyntaxKind.ThisKeyword) {
|
|
70
|
+
const field = owner.name.text;
|
|
71
|
+
if (field.length > "Service".length && field.endsWith("Service"))
|
|
72
|
+
calls.push([field.slice(0, -"Service".length), name.text]);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
ts.forEachChild(child, visit);
|
|
76
|
+
};
|
|
77
|
+
visit(node);
|
|
78
|
+
return calls;
|
|
79
|
+
}
|
|
80
|
+
}
|
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"]',
|
package/executors.ts
CHANGED
|
@@ -49,6 +49,7 @@ import { JsonFormatter } from "./jsonFormatter";
|
|
|
49
49
|
import { Linter } from "./linter";
|
|
50
50
|
import { ManifestDependencies } from "./manifestDependencies";
|
|
51
51
|
import { resolveRepoName } from "./repoIdentity";
|
|
52
|
+
import type { RouteSourceInfo } from "./routeSourceValidator";
|
|
52
53
|
import { AppInfo, isAkanFrameworkDependency, LibInfo, PkgInfo, WorkspaceInfo } from "./scanInfo";
|
|
53
54
|
import { Spinner } from "./spinner";
|
|
54
55
|
// Type-only: `getTypeChecker` loads it on demand to keep `typescript` out of the resident module graph.
|
|
@@ -66,6 +67,12 @@ export interface PageRoot {
|
|
|
66
67
|
keyPrefix: string;
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
export interface AppRouteSource {
|
|
71
|
+
key: string;
|
|
72
|
+
absPath: string;
|
|
73
|
+
info: RouteSourceInfo;
|
|
74
|
+
}
|
|
75
|
+
|
|
69
76
|
export interface WrittenFileContent extends FileContent {
|
|
70
77
|
written: boolean;
|
|
71
78
|
}
|
|
@@ -557,8 +564,8 @@ export class Executor {
|
|
|
557
564
|
return fileContents;
|
|
558
565
|
}
|
|
559
566
|
|
|
560
|
-
//
|
|
561
|
-
//
|
|
567
|
+
// Best-effort: `create-akan-workspace` scaffolds before `bun install`, with no local Biome binary yet, so a
|
|
568
|
+
// template emits Biome's form itself (`templates/biomeForm.ts`).
|
|
562
569
|
async #formatAppliedTemplate(fileContents: FileContent[]) {
|
|
563
570
|
const filePaths = fileContents
|
|
564
571
|
.map((fileContent) => fileContent.filePath)
|
|
@@ -1278,6 +1285,7 @@ export class AppExecutor extends SysExecutor {
|
|
|
1278
1285
|
}
|
|
1279
1286
|
|
|
1280
1287
|
#pageKeys: string[] | null = null;
|
|
1288
|
+
#routeSources: AppRouteSource[] = [];
|
|
1281
1289
|
#excludeDevOnlyPages = false;
|
|
1282
1290
|
async getPageKeys({ refresh }: { refresh?: boolean } = {}): Promise<string[]> {
|
|
1283
1291
|
if (this.#pageKeys && !refresh) return this.#pageKeys;
|
|
@@ -1288,6 +1296,7 @@ export class AppExecutor extends SysExecutor {
|
|
|
1288
1296
|
const devOnlyKeys = new Set<string>();
|
|
1289
1297
|
const devOnlyDirs: string[] = [];
|
|
1290
1298
|
const pageRoots = await this.getPageRoots();
|
|
1299
|
+
const routeSources: AppRouteSource[] = [];
|
|
1291
1300
|
const libKeyPrefixes = pageRoots.map((root) => root.keyPrefix).filter(Boolean);
|
|
1292
1301
|
for (const root of pageRoots) {
|
|
1293
1302
|
if (!(await FileSys.dirExists(root.dir))) continue;
|
|
@@ -1333,6 +1342,7 @@ export class AppExecutor extends SysExecutor {
|
|
|
1333
1342
|
rootLayout: isRootLayout,
|
|
1334
1343
|
pattern: parsed.pattern,
|
|
1335
1344
|
});
|
|
1345
|
+
routeSources.push({ key, absPath, info });
|
|
1336
1346
|
if (info.devOnly) {
|
|
1337
1347
|
devOnlyKeys.add(key);
|
|
1338
1348
|
//* A dev-only layout takes its whole subtree, or its pages would ship without their chrome.
|
|
@@ -1343,9 +1353,15 @@ export class AppExecutor extends SysExecutor {
|
|
|
1343
1353
|
}
|
|
1344
1354
|
}
|
|
1345
1355
|
pageKeys.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
1356
|
+
this.#routeSources = routeSources.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
1346
1357
|
this.#pageKeys = this.#excludeDevOnlyPages ? this.#dropDevOnlyPages(pageKeys, devOnlyKeys, devOnlyDirs) : pageKeys;
|
|
1347
1358
|
return this.#pageKeys;
|
|
1348
1359
|
}
|
|
1360
|
+
/** Each page and layout file `getPageKeys` read, with what its chain declares; overrides files are not listed. */
|
|
1361
|
+
async getRouteSources({ refresh }: { refresh?: boolean } = {}): Promise<AppRouteSource[]> {
|
|
1362
|
+
await this.getPageKeys({ refresh });
|
|
1363
|
+
return this.#routeSources;
|
|
1364
|
+
}
|
|
1349
1365
|
#dropDevOnlyPages(pageKeys: string[], devOnlyKeys: Set<string>, devOnlyDirs: string[]): string[] {
|
|
1350
1366
|
if (!devOnlyKeys.size) return pageKeys;
|
|
1351
1367
|
const isDevOnly = (key: string) => devOnlyKeys.has(key) || devOnlyDirs.some((dir) => key.startsWith(dir));
|
package/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ export type * from "./fileSys";
|
|
|
19
19
|
export type * from "./frontendBuild";
|
|
20
20
|
export type * from "./getRelatedCnsts";
|
|
21
21
|
export type * from "./incrementalBuilder";
|
|
22
|
+
export type * from "./maintainabilityContract";
|
|
22
23
|
export type * from "./mobile";
|
|
23
24
|
export type * from "./packageExportsMap";
|
|
24
25
|
export type * from "./prompter";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { AkanAppHost } from "../akanApp";
|
|
2
3
|
import { DevGeneratedIndexSync } from "../frontendBuild";
|
|
3
4
|
import { DevStabilityHarness, type DevStabilityHmrProbe } from "./devStabilityHarness";
|
|
4
5
|
|
|
@@ -467,7 +468,8 @@ export class FixtureService extends serve("fixture" as const, { serverMode: "bat
|
|
|
467
468
|
const harness = await createHarness();
|
|
468
469
|
const port = await harness.resolvePort();
|
|
469
470
|
// Child 0's deterministic ws port, held the way an orphaned replica from a killed run would hold it.
|
|
470
|
-
|
|
471
|
+
//? On the child's own 127.0.0.1: macOS lets a loopback bind share a port a wildcard listener holds.
|
|
472
|
+
const blocker = Bun.serve({ port: port + 10_000, hostname: "127.0.0.1", fetch: () => new Response("occupied") });
|
|
471
473
|
try {
|
|
472
474
|
const host = await harness.startHost();
|
|
473
475
|
await host.waitForLog(/falling back to an ephemeral port/);
|
|
@@ -583,9 +585,9 @@ describe("dev resource budgets", () => {
|
|
|
583
585
|
expect(await DevStabilityHarness.buildWorkerProcess(host.proc.pid)).toBeNull();
|
|
584
586
|
});
|
|
585
587
|
|
|
586
|
-
//? Between a fresh builder and one that has built a route, or the host stops enforcing it or never recycles.
|
|
587
|
-
//?
|
|
588
|
-
const CEILING_MB = process.platform === "
|
|
588
|
+
//? Between a fresh builder and one that has built a route, or the host stops enforcing it or never recycles. Linux
|
|
589
|
+
//? measured 101-115MiB fresh, 157-176MiB built and settled; macOS's footprint (Bun 1.4.4) 65MiB fresh, 109MiB built.
|
|
590
|
+
const CEILING_MB = process.platform === "darwin" ? 87 : 135;
|
|
589
591
|
|
|
590
592
|
budgetTest("recycles the builder at an unmeetable ceiling and keeps developing through it", async () => {
|
|
591
593
|
const harness = await createHarness();
|
|
@@ -635,9 +637,11 @@ describe("dev resource budgets", () => {
|
|
|
635
637
|
await host.waitForLogSince(mark, /pages-rebundle ok/, WAIT_MS).catch(() => undefined);
|
|
636
638
|
}
|
|
637
639
|
const settled = await DevStabilityHarness.builderProcess(host.proc.pid);
|
|
640
|
+
//? Read as the host reads it: on macOS `ps` also counts the pages the builder freed.
|
|
641
|
+
const settledRss = settled ? await AkanAppHost.readProcessRssBytes(settled.pid) : null;
|
|
638
642
|
const warned = /ceiling costs about one boot build per interval/.test(host.logs.join("").slice(start));
|
|
639
643
|
//? Past the interval (a slow machine) the next report recycles instead of warning: either answer settles it.
|
|
640
|
-
if ((
|
|
644
|
+
if ((settledRss ?? 0) >= CEILING_MB * MB && !warned)
|
|
641
645
|
await host.waitForLogSince(
|
|
642
646
|
settledFrom,
|
|
643
647
|
/ceiling costs about one boot build per interval|recycling builder pid=\d+|skipped: the builder fell to/,
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { KindBranchScanner } from "./kindBranchScanner";
|
|
4
|
+
import type { SourceFileInfo } from "./qualityScanner";
|
|
5
|
+
|
|
6
|
+
const filesOf = (files: Record<string, string>): SourceFileInfo[] =>
|
|
7
|
+
Object.entries(files).map(([file, content]) => ({
|
|
8
|
+
file,
|
|
9
|
+
absolutePath: `/repo/${file}`,
|
|
10
|
+
content,
|
|
11
|
+
sourceFile: ts.createSourceFile(
|
|
12
|
+
file,
|
|
13
|
+
content,
|
|
14
|
+
ts.ScriptTarget.Latest,
|
|
15
|
+
true,
|
|
16
|
+
file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
|
17
|
+
),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const pinConstant = `import { enumOf } from "akanjs/base";
|
|
21
|
+
export class PinKind extends enumOf("pinKind", ["videoScene", "videoTake", "memo"] as const) {}
|
|
22
|
+
export class PinStatus extends enumOf("pinStatus", ["active", "videoTake"] as const) {}
|
|
23
|
+
`;
|
|
24
|
+
const comparing = (expression: string) => `export const isIt = (pin: { kind: string }) => ${expression};\n`;
|
|
25
|
+
|
|
26
|
+
describe("KindBranchScanner", () => {
|
|
27
|
+
test("warns on a kind value compared in three files outside its module, naming them and the table to declare", () => {
|
|
28
|
+
const warnings = new KindBranchScanner().scan(
|
|
29
|
+
filesOf({
|
|
30
|
+
"apps/demo/lib/pin/pin.constant.ts": pinConstant,
|
|
31
|
+
"apps/demo/lib/pin/Pin.Zone.tsx": comparing('pin.kind === "videoScene"'),
|
|
32
|
+
"apps/demo/lib/board/Board.Zone.tsx": comparing('pin.kind === "videoScene"'),
|
|
33
|
+
"apps/demo/lib/take/take.store.ts": comparing('"videoScene" !== pin.kind'),
|
|
34
|
+
"apps/demo/ui/PinCard.tsx": `export const label = (kind: string) => {\n switch (kind) {\n case "videoScene":\n return 1;\n }\n};\n`,
|
|
35
|
+
"apps/demo/ui/PinCard.test.tsx": comparing('pin.kind === "videoScene"'),
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
expect(warnings).toHaveLength(1);
|
|
40
|
+
expect(warnings[0]).toMatchObject({
|
|
41
|
+
rule: "akan.global.kind-branch",
|
|
42
|
+
severity: "warning",
|
|
43
|
+
file: "apps/demo/lib/pin/pin.constant.ts",
|
|
44
|
+
line: 2,
|
|
45
|
+
message: '"videoScene" of cnst.PinKind is branched on in 3 files outside the enum\'s module.',
|
|
46
|
+
locations: [
|
|
47
|
+
{ file: "apps/demo/lib/board/Board.Zone.tsx", line: 1 },
|
|
48
|
+
{ file: "apps/demo/lib/take/take.store.ts", line: 1 },
|
|
49
|
+
{ file: "apps/demo/ui/PinCard.tsx", line: 3 },
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
expect(warnings[0]?.fix).toContain('{ [kind in cnst.PinKind["value"]]: … }');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("reads only a subject named for a kind, and only enums named for one", () => {
|
|
56
|
+
const warnings = new KindBranchScanner().scan(
|
|
57
|
+
filesOf({
|
|
58
|
+
"apps/demo/lib/pin/pin.constant.ts": pinConstant,
|
|
59
|
+
"apps/demo/lib/a/A.Zone.tsx": comparing('pin.status === "active" || label === "videoScene"'),
|
|
60
|
+
"apps/demo/lib/b/B.Zone.tsx": comparing('pin.status === "active" || label === "videoScene"'),
|
|
61
|
+
"apps/demo/lib/c/C.Zone.tsx": comparing('pin.status === "active" || label === "videoScene"'),
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
expect(warnings).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("groups the enums sharing a value, reached from the app's own lib or a lib, never another app or lib", () => {
|
|
69
|
+
const warnings = new KindBranchScanner().scan(
|
|
70
|
+
filesOf({
|
|
71
|
+
"apps/demo/lib/pin/pin.constant.ts": pinConstant,
|
|
72
|
+
"libs/media/lib/clip/clip.constant.ts": `export class ClipType extends enumOf("clipType", ["videoTake"] as const) {}\n`,
|
|
73
|
+
"apps/demo/lib/a/A.Zone.tsx": comparing('pin.kind === "videoTake"'),
|
|
74
|
+
"apps/demo/lib/b/B.Zone.tsx": comparing('pin.kind === "videoTake"'),
|
|
75
|
+
"apps/demo/lib/c/C.Zone.tsx": comparing('pin.kind === "videoTake"'),
|
|
76
|
+
"apps/other/lib/a/A.Zone.tsx": comparing('pin.kind === "videoTake"'),
|
|
77
|
+
"apps/other/lib/b/B.Zone.tsx": comparing('pin.kind === "videoTake"'),
|
|
78
|
+
"libs/shared/lib/a/A.Zone.tsx": comparing('pin.kind === "videoTake"'),
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
expect(warnings.map(({ file, message }) => ({ file, message }))).toEqual([
|
|
83
|
+
{
|
|
84
|
+
file: "apps/demo/lib/pin/pin.constant.ts",
|
|
85
|
+
message: '"videoTake" of cnst.PinKind / cnst.ClipType is branched on in 3 files outside the enum\'s module.',
|
|
86
|
+
},
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
});
|