@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
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { ComponentWriteChainScanner } from "./componentWriteChainScanner";
|
|
4
|
+
import type { SourceFileInfo } from "./qualityScanner";
|
|
5
|
+
import { SignalMutationIndex } from "./signalMutationIndex";
|
|
6
|
+
import { WritingActionIndex } from "./writingActionIndex";
|
|
7
|
+
|
|
8
|
+
const fileOf = (file: string, content: string): SourceFileInfo => ({
|
|
9
|
+
file,
|
|
10
|
+
absolutePath: `/repo/${file}`,
|
|
11
|
+
content,
|
|
12
|
+
sourceFile: ts.createSourceFile(
|
|
13
|
+
file,
|
|
14
|
+
content,
|
|
15
|
+
ts.ScriptTarget.Latest,
|
|
16
|
+
true,
|
|
17
|
+
file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
|
18
|
+
),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const index = new WritingActionIndex(
|
|
22
|
+
new SignalMutationIndex([
|
|
23
|
+
fileOf(
|
|
24
|
+
"apps/demo/lib/court/court.signal.ts",
|
|
25
|
+
`export class CourtSlice extends slice(srv.court, { guards: { root: Admin, cru: User } }, () => ({})) {}
|
|
26
|
+
export class CourtEndpoint extends endpoint(srv.court, ({ query, mutation }) => ({
|
|
27
|
+
skipNvrOnCourt: mutation(cnst.Court, { guards: [User] }).param("courtId", ID).exec(() => null),
|
|
28
|
+
courtCount: query(Int, { guards: [User] }).exec(() => 0),
|
|
29
|
+
})) {}
|
|
30
|
+
`,
|
|
31
|
+
),
|
|
32
|
+
]),
|
|
33
|
+
[
|
|
34
|
+
fileOf(
|
|
35
|
+
"apps/demo/lib/court/court.store.ts",
|
|
36
|
+
`import { fetch, sig } from "../useClient";
|
|
37
|
+
export class CourtStore extends store(sig.court, () => ({})) {
|
|
38
|
+
async skipNvrOnCourt(courtId: string) {
|
|
39
|
+
this.setCourt(await fetch.skipNvrOnCourt(courtId));
|
|
40
|
+
}
|
|
41
|
+
async loadCourtCount() {
|
|
42
|
+
this.set({ count: await fetch.courtCount() });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
`,
|
|
46
|
+
),
|
|
47
|
+
],
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const chainsIn = (body: string) =>
|
|
51
|
+
new ComponentWriteChainScanner(index).scan(
|
|
52
|
+
fileOf(
|
|
53
|
+
"apps/demo/lib/court/Court.Zone.tsx",
|
|
54
|
+
`"use client";\nimport { st } from "@apps/demo/client";\n\nexport const Install = ({ courtId }: { courtId: string }) => {\n${body}\n};\n`,
|
|
55
|
+
).sourceFile,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
describe("ComponentWriteChainScanner", () => {
|
|
59
|
+
test("reports one handler sending two writing actions at the second", () => {
|
|
60
|
+
const chains = chainsIn(` const register = st
|
|
61
|
+
.tool("registerNvr")
|
|
62
|
+
.exec(async () => {
|
|
63
|
+
await st.do.updateCourt(courtId, {});
|
|
64
|
+
await st.do.loadCourtCount();
|
|
65
|
+
await st.do.skipNvrOnCourt(courtId);
|
|
66
|
+
});
|
|
67
|
+
return <button type="button" onClick={() => void register()} />;`);
|
|
68
|
+
|
|
69
|
+
expect(chains).toEqual([
|
|
70
|
+
{ component: "Install", line: 10, writes: ["st.do.updateCourt", "st.do.skipNvrOnCourt"], writeCount: 2 },
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("passes two writes in two separate handlers, a render callback included", () => {
|
|
75
|
+
const chains = chainsIn(` const courtIds = [courtId];
|
|
76
|
+
return (
|
|
77
|
+
<div>
|
|
78
|
+
{courtIds.map((id) => (
|
|
79
|
+
<div key={id}>
|
|
80
|
+
<button type="button" onClick={() => void st.do.removeCourt(id)} />
|
|
81
|
+
<button type="button" onClick={() => void st.do.skipNvrOnCourt(id)} />
|
|
82
|
+
</div>
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
);`);
|
|
86
|
+
|
|
87
|
+
expect(chains).toEqual([]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("reads exclusive branches as one and an array or promise callback as part of its handler", () => {
|
|
91
|
+
const chains = chainsIn(` const onSave = async (isNew: boolean) => {
|
|
92
|
+
if (isNew) await st.do.createCourt({});
|
|
93
|
+
else await st.do.updateCourt(courtId, {});
|
|
94
|
+
};
|
|
95
|
+
const onRemoveAll = async (ids: string[]) => {
|
|
96
|
+
await Promise.all(ids.map((id) => st.do.removeCourt(id)));
|
|
97
|
+
await st.do.submitCourt().then(() => st.do.mergeCourt(courtId, {}));
|
|
98
|
+
};
|
|
99
|
+
return <button type="button" onClick={() => void onSave(true)} />;`);
|
|
100
|
+
|
|
101
|
+
expect(chains).toEqual([
|
|
102
|
+
{
|
|
103
|
+
component: "Install",
|
|
104
|
+
line: 11,
|
|
105
|
+
writes: ["st.do.removeCourt (per item)", "st.do.submitCourt", "st.do.mergeCourt"],
|
|
106
|
+
writeCount: 4,
|
|
107
|
+
},
|
|
108
|
+
]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("reads one write per item as a chain, in a loop or a per-element callback, and a hook like a component", () => {
|
|
112
|
+
const loop = chainsIn(` const onRemoveAll = async (ids: string[]) => {
|
|
113
|
+
for (const id of ids) await st.do.removeCourt(id);
|
|
114
|
+
};
|
|
115
|
+
return <button type="button" onClick={() => void onRemoveAll([courtId])} />;`);
|
|
116
|
+
const hook = new ComponentWriteChainScanner(index).scan(
|
|
117
|
+
fileOf(
|
|
118
|
+
"apps/demo/webkit/useBulk.tsx",
|
|
119
|
+
'import { st } from "@apps/demo/client";\n\nexport const useBulk = () => ({\n run: async (ids: string[]) => {\n await Promise.all(ids.map((id) => st.do.removeCourt(id)));\n },\n});\n',
|
|
120
|
+
).sourceFile,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
expect(loop).toEqual([{ component: "Install", line: 6, writes: ["st.do.removeCourt (per item)"], writeCount: 2 }]);
|
|
124
|
+
expect(hook).toEqual([{ component: "useBulk", line: 5, writes: ["st.do.removeCourt (per item)"], writeCount: 2 }]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("ignores a component file that does not import st", () => {
|
|
128
|
+
const chains = new ComponentWriteChainScanner(index).scan(
|
|
129
|
+
fileOf(
|
|
130
|
+
"apps/demo/ui/Plain.tsx",
|
|
131
|
+
"export const Plain = () => {\n const onClick = async () => {\n await st.do.createCourt({});\n await st.do.removeCourt('a');\n };\n return null;\n};\n",
|
|
132
|
+
).sourceFile,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(chains).toEqual([]);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { ComponentSizeScanner } from "./componentSizeScanner";
|
|
3
|
+
import { StoreAccessScanner } from "./storeAccessScanner";
|
|
4
|
+
import { WritePath } from "./writePath";
|
|
5
|
+
import type { WritingActionIndex } from "./writingActionIndex";
|
|
6
|
+
|
|
7
|
+
export interface ComponentWriteChain {
|
|
8
|
+
component: string;
|
|
9
|
+
line: number;
|
|
10
|
+
writes: string[];
|
|
11
|
+
writeCount: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
//? A callback an array method or a promise runs belongs to the handler that started it; any other function is one.
|
|
15
|
+
export class ComponentWriteChainScanner {
|
|
16
|
+
static readonly #inlineCallers = new Set([
|
|
17
|
+
"map",
|
|
18
|
+
"flatMap",
|
|
19
|
+
"forEach",
|
|
20
|
+
"filter",
|
|
21
|
+
"reduce",
|
|
22
|
+
"some",
|
|
23
|
+
"every",
|
|
24
|
+
"find",
|
|
25
|
+
"findIndex",
|
|
26
|
+
"then",
|
|
27
|
+
"catch",
|
|
28
|
+
"finally",
|
|
29
|
+
]);
|
|
30
|
+
readonly #index: WritingActionIndex;
|
|
31
|
+
|
|
32
|
+
constructor(index: WritingActionIndex) {
|
|
33
|
+
this.#index = index;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
scan(sourceFile: ts.SourceFile): ComponentWriteChain[] {
|
|
37
|
+
const stNames = StoreAccessScanner.stNamesOf(sourceFile);
|
|
38
|
+
if (!stNames.size) return [];
|
|
39
|
+
const path = new WritePath(
|
|
40
|
+
(call) => {
|
|
41
|
+
const action = ComponentWriteChainScanner.#actionOf(call.expression, stNames);
|
|
42
|
+
return !!action && this.#index.isWritingAction(action);
|
|
43
|
+
},
|
|
44
|
+
{ enters: ComponentWriteChainScanner.#isInlineCallback },
|
|
45
|
+
);
|
|
46
|
+
return ComponentSizeScanner.componentsAndHooksOf(sourceFile).flatMap(({ name, node }) =>
|
|
47
|
+
ComponentWriteChainScanner.#handlersIn(node).flatMap((handler) => {
|
|
48
|
+
const writes = path.along(handler);
|
|
49
|
+
const writeCount = path.countOf(writes);
|
|
50
|
+
const second = writes[1] ?? writes[0];
|
|
51
|
+
if (writeCount < 2 || !second) return [];
|
|
52
|
+
return [
|
|
53
|
+
{
|
|
54
|
+
component: name,
|
|
55
|
+
line: sourceFile.getLineAndCharacterOfPosition(second.getStart(sourceFile)).line + 1,
|
|
56
|
+
writes: writes.map((call) => path.labelOf(call, sourceFile)),
|
|
57
|
+
writeCount,
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
static #handlersIn(component: ts.Node) {
|
|
65
|
+
const bodies: ts.ConciseBody[] = [];
|
|
66
|
+
const visit = (node: ts.Node) => {
|
|
67
|
+
if (
|
|
68
|
+
ComponentWriteChainScanner.#isFunction(node) &&
|
|
69
|
+
node.body &&
|
|
70
|
+
!ComponentWriteChainScanner.#isInlineCallback(node)
|
|
71
|
+
)
|
|
72
|
+
bodies.push(node.body);
|
|
73
|
+
ts.forEachChild(node, visit);
|
|
74
|
+
};
|
|
75
|
+
visit(component);
|
|
76
|
+
return bodies;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
static #isFunction(node: ts.Node): node is ts.FunctionLikeDeclaration {
|
|
80
|
+
return (
|
|
81
|
+
ts.isArrowFunction(node) ||
|
|
82
|
+
ts.isFunctionExpression(node) ||
|
|
83
|
+
ts.isFunctionDeclaration(node) ||
|
|
84
|
+
ts.isMethodDeclaration(node)
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
static #isInlineCallback(fn: ts.Node) {
|
|
89
|
+
const call = fn.parent;
|
|
90
|
+
return (
|
|
91
|
+
!!call &&
|
|
92
|
+
ts.isCallExpression(call) &&
|
|
93
|
+
call.arguments.some((arg) => arg === fn) &&
|
|
94
|
+
ts.isPropertyAccessExpression(call.expression) &&
|
|
95
|
+
ComponentWriteChainScanner.#inlineCallers.has(call.expression.name.text)
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
static #actionOf(callee: ts.Expression, stNames: Set<string>) {
|
|
100
|
+
if (!ts.isPropertyAccessExpression(callee) || !ts.isPropertyAccessExpression(callee.expression)) return null;
|
|
101
|
+
const { expression: st, name } = callee.expression;
|
|
102
|
+
return ts.isIdentifier(st) && stNames.has(st.text) && name.text === "do" ? callee.name.text : null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -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
|
+
});
|
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
|
+
}
|
|
@@ -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/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";
|