@akanjs/devkit 3.0.0-alpha.35 → 3.0.0-alpha.36
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/biome.base.json +4 -0
- package/formSetterScanner.test.ts +80 -0
- package/formSetterScanner.ts +92 -0
- package/lint/no-unpublished-form-setter.grit +41 -0
- package/package.json +2 -2
- package/qualityScanner.ts +6 -1
package/biome.base.json
CHANGED
|
@@ -178,6 +178,10 @@
|
|
|
178
178
|
"includes": ["{apps,libs}/**/*.Util.tsx", "{apps,libs}/**/*.Zone.tsx"],
|
|
179
179
|
"plugins": ["./node_modules/@akanjs/devkit/lint/no-model-type-in-util-zone.grit"]
|
|
180
180
|
},
|
|
181
|
+
{
|
|
182
|
+
"includes": ["{apps,libs}/**/*.tsx", "!**/*.test.tsx", "!**/*.spec.tsx"],
|
|
183
|
+
"plugins": ["./node_modules/@akanjs/devkit/lint/no-unpublished-form-setter.grit"]
|
|
184
|
+
},
|
|
181
185
|
{
|
|
182
186
|
"includes": [
|
|
183
187
|
"**/ui/**/*.ts",
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { FormSetterScanner } from "./formSetterScanner";
|
|
4
|
+
import type { SourceFileInfo } from "./qualityScanner";
|
|
5
|
+
|
|
6
|
+
const fileOf = (file: string, content: string): SourceFileInfo => ({
|
|
7
|
+
file,
|
|
8
|
+
absolutePath: `/tmp/${file}`,
|
|
9
|
+
content,
|
|
10
|
+
sourceFile: ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const scan = (content: string) =>
|
|
14
|
+
new FormSetterScanner().scan([fileOf("apps/demo/lib/task/Task.Template.tsx", content)]);
|
|
15
|
+
|
|
16
|
+
describe("FormSetterScanner", () => {
|
|
17
|
+
test("a setter passed by reference publishes, so nothing is reported", () => {
|
|
18
|
+
expect(
|
|
19
|
+
scan(`export const General = () => (
|
|
20
|
+
<Field.Text value={taskForm.title} onChange={st.do.setTitleOnTask} />
|
|
21
|
+
);
|
|
22
|
+
`),
|
|
23
|
+
).toEqual([]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("every wrapper shape is counted once per field, whatever the wrapper was for", () => {
|
|
27
|
+
const warnings = scan(`export const General = () => (
|
|
28
|
+
<>
|
|
29
|
+
<Field.Text onChange={st.do.setTitleOnTask} />
|
|
30
|
+
<Field.ToggleSelect onChange={(type) => { st.do.setTypeOnTask(type); }} />
|
|
31
|
+
<Field.Phone onChange={(phone) => st.do.setPhoneOnTask(formatPhone(phone))} />
|
|
32
|
+
<Field.Parent
|
|
33
|
+
onChange={(project) => {
|
|
34
|
+
st.do.setProjectOnTask(project);
|
|
35
|
+
if (project) st.do.addMembersOnTask(project.members ?? []);
|
|
36
|
+
}}
|
|
37
|
+
/>
|
|
38
|
+
</>
|
|
39
|
+
);
|
|
40
|
+
`);
|
|
41
|
+
|
|
42
|
+
expect(warnings).toHaveLength(1);
|
|
43
|
+
expect(warnings[0]?.rule).toBe("akan.agent.unpublished-form-setter");
|
|
44
|
+
expect(warnings[0]?.scope).toBe("agent");
|
|
45
|
+
expect(warnings[0]?.message).toContain("setPhoneOnTask, setProjectOnTask, setTypeOnTask");
|
|
46
|
+
expect(warnings[0]?.locations).toHaveLength(3);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("a nested path write is unannotatable by design and is not counted", () => {
|
|
50
|
+
expect(
|
|
51
|
+
scan(`export const Rows = ({ idx }: { idx: number }) => (
|
|
52
|
+
<Field.Text onChange={(title) => st.do.writeOnTask(\`payments.\${idx}.title\`, title)} />
|
|
53
|
+
);
|
|
54
|
+
`),
|
|
55
|
+
).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("a zero-parameter handler is a button setting a constant, not a form control", () => {
|
|
59
|
+
expect(
|
|
60
|
+
scan(`export const Actions = () => (
|
|
61
|
+
<Button onClick={() => st.do.setStatusOnTask("done")}>Done</Button>
|
|
62
|
+
);
|
|
63
|
+
`),
|
|
64
|
+
).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a non-setter action behind a wrapper is somebody else's rule", () => {
|
|
68
|
+
expect(
|
|
69
|
+
scan(`export const Filters = () => (
|
|
70
|
+
<Select onChange={(ids) => st.do.setQueryArgsOfTaskInSelf(ids)} />
|
|
71
|
+
);
|
|
72
|
+
`),
|
|
73
|
+
).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("only .tsx is scanned", () => {
|
|
77
|
+
const content = `export const set = (v: string) => <X onChange={(t) => st.do.setTitleOnTask(t)} />;\n`;
|
|
78
|
+
expect(new FormSetterScanner().scan([fileOf("apps/demo/lib/task/task.store.ts", content)])).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The inventory of model fields a screen writes but does not publish.
|
|
6
|
+
*
|
|
7
|
+
* A control handed `onChange={st.do.setTitleOnTask}` **by reference** names the field it writes, so it emits
|
|
8
|
+
* `data-akan-action` and `useFieldTool` publishes the field to the in-page agent. Any wrapper around that setter is
|
|
9
|
+
* an anonymous closure carrying neither, and the field goes quiet — for the agent, for an E2E selector, and for the
|
|
10
|
+
* accessibility tree.
|
|
11
|
+
*
|
|
12
|
+
* `no-unpublished-form-setter.grit` is the per-line enforcement, and it fires only on a pure forwarding wrapper,
|
|
13
|
+
* because every other shape has a legitimate reading and a lint error would be wrong. This is the other half: the
|
|
14
|
+
* per-file count of fields that ended up unreachable whatever the reason, which is the number worth watching. A
|
|
15
|
+
* warning rather than an error for the same reason — the remedy depends on why the wrapper is there.
|
|
16
|
+
*
|
|
17
|
+
* Only a handler that takes a parameter is counted. A zero-parameter handler (`onClick={() => st.do.setStatusOnUser
|
|
18
|
+
* ("active")}`) is a button setting a constant, not a form control, and its remedy is an `st.tool` beside it.
|
|
19
|
+
*/
|
|
20
|
+
export class FormSetterScanner {
|
|
21
|
+
static #fieldSetter = /^set[A-Za-z0-9_$]*On[A-Za-z0-9_$]*$/;
|
|
22
|
+
|
|
23
|
+
scan(sourceFiles: SourceFileInfo[]): QualityWarning[] {
|
|
24
|
+
return sourceFiles.filter((sourceFile) => sourceFile.file.endsWith(".tsx")).flatMap((f) => this.#scanFile(f));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#scanFile({ file, sourceFile }: SourceFileInfo): QualityWarning[] {
|
|
28
|
+
const wrapped: { setter: string; line: number }[] = [];
|
|
29
|
+
const visit = (node: ts.Node) => {
|
|
30
|
+
if (ts.isJsxAttribute(node)) {
|
|
31
|
+
const setter = FormSetterScanner.#wrappedSetterOf(node);
|
|
32
|
+
if (setter) wrapped.push({ setter, line: FormSetterScanner.#lineOf(sourceFile, node) });
|
|
33
|
+
}
|
|
34
|
+
ts.forEachChild(node, visit);
|
|
35
|
+
};
|
|
36
|
+
visit(sourceFile);
|
|
37
|
+
if (!wrapped.length) return [];
|
|
38
|
+
const setters = [...new Set(wrapped.map((entry) => entry.setter))].sort();
|
|
39
|
+
return [
|
|
40
|
+
{
|
|
41
|
+
rule: "akan.agent.unpublished-form-setter",
|
|
42
|
+
scope: "agent",
|
|
43
|
+
severity: "warning",
|
|
44
|
+
message:
|
|
45
|
+
setters.length === 1
|
|
46
|
+
? `${setters[0]} is reached through a wrapper, so that field publishes no agent tool and carries no data-akan-action.`
|
|
47
|
+
: `${setters.length} field setters are reached through a wrapper, so those fields publish no agent tool and carry no data-akan-action: ${setters.join(", ")}.`,
|
|
48
|
+
file,
|
|
49
|
+
line: wrapped[0]?.line,
|
|
50
|
+
locations: wrapped.map(({ line }) => ({ file, line })),
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The setter a handler prop writes behind a wrapper, or null when it is passed by reference or absent. */
|
|
56
|
+
static #wrappedSetterOf(attribute: ts.JsxAttribute): string | null {
|
|
57
|
+
if (!ts.isIdentifier(attribute.name) || !/^on[A-Z]/.test(attribute.name.text)) return null;
|
|
58
|
+
const initializer = attribute.initializer;
|
|
59
|
+
if (!initializer || !ts.isJsxExpression(initializer) || !initializer.expression) return null;
|
|
60
|
+
const handler = initializer.expression;
|
|
61
|
+
if (!ts.isArrowFunction(handler) && !ts.isFunctionExpression(handler)) return null;
|
|
62
|
+
if (!handler.parameters.length) return null;
|
|
63
|
+
return FormSetterScanner.#setterCallIn(handler.body);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
static #setterCallIn(node: ts.Node): string | null {
|
|
67
|
+
let found: string | null = null;
|
|
68
|
+
const visit = (current: ts.Node) => {
|
|
69
|
+
if (found) return;
|
|
70
|
+
if (ts.isCallExpression(current) && ts.isPropertyAccessExpression(current.expression)) {
|
|
71
|
+
const { expression, name } = current.expression;
|
|
72
|
+
if (
|
|
73
|
+
ts.isPropertyAccessExpression(expression) &&
|
|
74
|
+
ts.isIdentifier(expression.expression) &&
|
|
75
|
+
expression.expression.text === "st" &&
|
|
76
|
+
expression.name.text === "do" &&
|
|
77
|
+
FormSetterScanner.#fieldSetter.test(name.text)
|
|
78
|
+
) {
|
|
79
|
+
found = name.text;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
ts.forEachChild(current, visit);
|
|
84
|
+
};
|
|
85
|
+
visit(node);
|
|
86
|
+
return found;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
static #lineOf(sourceFile: ts.SourceFile, node: ts.Node) {
|
|
90
|
+
return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// A form control publishes its own setter by reading the action off the function it was handed
|
|
5
|
+
// (`actionTagOf` in pkgs/akanjs/store/actionTag.ts). A setter passed by reference names the field it
|
|
6
|
+
// writes, so the control emits `data-akan-action` and `useFieldTool` publishes the field to the in-page
|
|
7
|
+
// agent. An arrow that only forwards its argument is a fresh anonymous closure carrying neither, so the
|
|
8
|
+
// field silently becomes unreachable to the agent, to an E2E selector, and to the accessibility tree —
|
|
9
|
+
// while looking identical to a reader. Generated field setters take exactly one value
|
|
10
|
+
// (`makeFormSetter` in pkgs/akanjs/store/action.ts), so the wrapper never changes what runs.
|
|
11
|
+
//
|
|
12
|
+
// Only a pure forwarding body is reported: the argument reaches the setter unchanged, as the sole
|
|
13
|
+
// statement. Each other shape has its own home, so none of them is reported here:
|
|
14
|
+
//
|
|
15
|
+
// normalize `(v) => set(formatPhone(v))` -> the control's own `transform` prop, which every text and
|
|
16
|
+
// number `Field.*` already takes. Keep `onChange` a reference.
|
|
17
|
+
// composite `(v) => { set(v); other(v); }` -> a store action named `set<Field>On<Model>` writing through
|
|
18
|
+
// `super.set<Field>On<Model>(v)`; `st.do` tags every action by
|
|
19
|
+
// name, so the reference publishes again.
|
|
20
|
+
// nested `(v) => writeOnX('a.3.b', v)` -> unannotatable by design. An agent reaches an embedded row
|
|
21
|
+
// through `fill<Model>Form`, which waves composites through.
|
|
22
|
+
//
|
|
23
|
+
// A typed parameter (`(v: string) => st.do.setVOnX(v)`) is not matched: the parameter and the argument
|
|
24
|
+
// bind to different text, so the metavariable cannot unify them. Under-reporting, not a false positive.
|
|
25
|
+
//
|
|
26
|
+
JsxAttribute() as $attr where {
|
|
27
|
+
$attr <: contains JsArrowFunctionExpression() as $arrow,
|
|
28
|
+
$arrow <: `($p) => $body`,
|
|
29
|
+
// One bare parameter. A metavariable matches a whole parameter list leniently, so `(a, b)` binds the
|
|
30
|
+
// text "a, b" and is excluded here rather than by the snippet.
|
|
31
|
+
$p <: r"[A-Za-z_$][A-Za-z0-9_$]*",
|
|
32
|
+
$body <: contains `st.do.$action($p)` as $call,
|
|
33
|
+
$action <: r"set[A-Za-z0-9_$]*On[A-Za-z0-9_$]*",
|
|
34
|
+
// The body is that call and nothing else — `r"..."` is a full match, so an extra statement fails it.
|
|
35
|
+
$body <: r"\{?\s*(?:void\s+|await\s+)?st\.do\.[A-Za-z0-9_$]+\([A-Za-z_$][A-Za-z0-9_$]*\)\s*;?\s*\}?",
|
|
36
|
+
register_diagnostic(
|
|
37
|
+
span = $arrow,
|
|
38
|
+
message = "This arrow only forwards its argument, and an anonymous closure names no action — the control publishes no agent tool and emits no data-akan-action for the field. Pass the setter by reference: onChange={st.do.setTypeOnTicket}. A wrapper that does more is not reported: normalization belongs in the control's transform prop, a multi-write belongs in a store action named set<Field>On<Model>, and a nested path through writeOnX is reached by the agent through fill<Model>Form.",
|
|
39
|
+
severity = "error"
|
|
40
|
+
)
|
|
41
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.36",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"@langchain/openai": "^1.4.6",
|
|
46
46
|
"@tailwindcss/node": "^4.3.0",
|
|
47
47
|
"@trapezedev/project": "^7.1.4",
|
|
48
|
-
"akanjs": "3.0.0-alpha.
|
|
48
|
+
"akanjs": "3.0.0-alpha.36",
|
|
49
49
|
"chalk": "^5.6.2",
|
|
50
50
|
"commander": "^14.0.3",
|
|
51
51
|
"dayjs": "^1.11.20",
|
package/qualityScanner.ts
CHANGED
|
@@ -5,11 +5,12 @@ import { RESERVED_ROUTE_CONFIG_EXPORTS } from "akanjs/common";
|
|
|
5
5
|
import ignore from "ignore";
|
|
6
6
|
import ts from "typescript";
|
|
7
7
|
import { AbstractDoc } from "./abstractDoc";
|
|
8
|
+
import { FormSetterScanner } from "./formSetterScanner";
|
|
8
9
|
import { formatSsrBalance, type SsrBalanceEntry, SsrScanner } from "./ssrScanner";
|
|
9
10
|
import { appRootAllowedFiles, libFacetRootAllowedFiles } from "./workspaceLayout";
|
|
10
11
|
|
|
11
12
|
type QualitySeverity = "warning";
|
|
12
|
-
type QualityScope = "global" | "file" | "convention" | "layout" | "ssr";
|
|
13
|
+
type QualityScope = "global" | "file" | "convention" | "layout" | "ssr" | "agent";
|
|
13
14
|
|
|
14
15
|
export interface QualityWarning {
|
|
15
16
|
rule: string;
|
|
@@ -88,6 +89,7 @@ const SUGGESTED_RULES = [
|
|
|
88
89
|
"Keep apps/*/lib and libs/*/lib root files limited to generated support facets such as cnst.ts, db.ts, dict.ts, sig.ts, srv.ts, st.ts, useClient.ts, and useServer.ts.",
|
|
89
90
|
"Use domain module folders consistently: lib/<model> for database modules, lib/_<service> for service modules, and lib/__scalar/<scalar> for scalar modules.",
|
|
90
91
|
"Keep module UI filenames predictable: database modules use <Model>.Template/Unit/Util/View/Zone.tsx, service modules use <Service>.Util/Zone.tsx, and scalar modules use <Scalar>.Template/Unit.tsx.",
|
|
92
|
+
"Hand a form control its store setter by reference so the field publishes an agent tool; put normalization in the control's transform prop and a multi-write in a store action of the same name.",
|
|
91
93
|
"Move shared app utilities to apps/*/common instead of creating apps/*/base.",
|
|
92
94
|
"Avoid large mixed-purpose class files; class export files should import helpers from neighboring utility files instead of declaring them inline.",
|
|
93
95
|
];
|
|
@@ -122,6 +124,8 @@ const RULE_FIXES: Record<string, string> = {
|
|
|
122
124
|
"Move the Window augmentation into an approved browser integration file and keep it isolated.",
|
|
123
125
|
"akan.file.prototype-mutation": "Avoid prototype mutation, or isolate it in an approved low-level integration file.",
|
|
124
126
|
"akan.file.class-export-global-declaration": "Move the helper to a sibling file and import it into the class module.",
|
|
127
|
+
"akan.agent.unpublished-form-setter":
|
|
128
|
+
"Pass the setter by reference where the wrapper only forwards (onChange={st.do.setTitleOnTask}); move normalization into the control's own transform prop; move a multi-write into a store action named set<Field>On<Model> that writes through super.set<Field>On<Model>(v).",
|
|
125
129
|
"akan.file.component-internal-declaration":
|
|
126
130
|
"Move the type or helper to a type/util file in ui/, webkit/, or common/ by purpose. If it is the component's props, declare it as `interface <Component>Props`.",
|
|
127
131
|
"akan.file.component-export":
|
|
@@ -173,6 +177,7 @@ export class AkanQualityScanner {
|
|
|
173
177
|
...sourceFiles.flatMap((sourceFile) => this.#scanConventionQuality(sourceFile)),
|
|
174
178
|
...sourceFiles.flatMap((sourceFile) => this.#scanLayoutQuality(sourceFile)),
|
|
175
179
|
...abstractFiles.flatMap((abstractFile) => this.#scanAbstractQuality(abstractFile)),
|
|
180
|
+
...new FormSetterScanner().scan(sourceFiles),
|
|
176
181
|
...ssr.warnings,
|
|
177
182
|
];
|
|
178
183
|
|