@webpresso/agent-config 0.4.1 → 0.4.3

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/README.md CHANGED
@@ -29,11 +29,44 @@ also re-exports the shared low-level primitives sourced from
29
29
  - `@webpresso/agent-config/vitest/*`
30
30
  - `@webpresso/agent-config/stryker`
31
31
  - `@webpresso/agent-config/workers-test`
32
+ - `@webpresso/agent-config/oxlint/*` (policy plugins)
32
33
 
33
34
  The Vitest surface includes stable group ordering, explicit staged-worker
34
35
  selection, and root-aware Node project construction. Consumers keep ownership
35
36
  of package-specific worker defaults, include patterns, aliases, and setup files.
36
37
 
38
+ ## Oxlint policy plugins (consumer SSOT)
39
+
40
+ Monorepo and other consumer repos load shared policy plugins from
41
+ **`@webpresso/agent-config/oxlint/*`** — not from `@webpresso/agent-kit`.
42
+
43
+ | Subpath | Purpose |
44
+ | ---------------------------------------------------- | ----------------------------------------- |
45
+ | `@webpresso/agent-config/oxlint/code-safety` | `as-any` / swallowed-error audit |
46
+ | `@webpresso/agent-config/oxlint/foundation-purity` | foundation tier purity |
47
+ | `@webpresso/agent-config/oxlint/graphql-conventions` | GraphQL app conventions |
48
+ | `@webpresso/agent-config/oxlint/import-hygiene` | relative-parent, SEA schema/ui ban |
49
+ | `@webpresso/agent-config/oxlint/monorepo-paths` | hardcoded repo-root / cross-package paths |
50
+ | `@webpresso/agent-config/oxlint/query-patterns` | TanStack Query patterns |
51
+ | `@webpresso/agent-config/oxlint/testing-quality` | weak assertion / mock hygiene |
52
+ | `@webpresso/agent-config/oxlint/tier-boundaries` | package-tier import direction |
53
+ | `@webpresso/agent-config/oxlint/path-roles` | shared path classifiers (no AST) |
54
+
55
+ Example `jsPlugins` (package names resolve through Node `exports`; oxlint uses
56
+ createRequire so each subpath needs `default`/`require` conditions):
57
+
58
+ ```json
59
+ {
60
+ "jsPlugins": [
61
+ "@webpresso/agent-config/oxlint/import-hygiene",
62
+ "@webpresso/agent-config/oxlint/tier-boundaries"
63
+ ]
64
+ }
65
+ ```
66
+
67
+ agent-kit re-exports the same modules for its inject-relative `oxlintrc.json`
68
+ used by `wp lint` on consumers that do not ship a local `.oxlintrc`.
69
+
37
70
  ## Relationship to other packages
38
71
 
39
72
  - `@webpresso/agent-config` — consumer-installed package surface
@@ -0,0 +1,18 @@
1
+ declare const plugin: {
2
+ meta: {
3
+ name: string;
4
+ };
5
+ rules: {
6
+ "as-any-audit": {
7
+ create(context: any): {
8
+ TSAsExpression(node: any): void;
9
+ };
10
+ };
11
+ "no-swallowed-errors": {
12
+ create(context: any): {
13
+ CatchClause(node: any): void;
14
+ };
15
+ };
16
+ };
17
+ };
18
+ export default plugin;
@@ -0,0 +1,89 @@
1
+ // @ts-nocheck
2
+ // Webpresso code safety rules — replaces GritQL patterns:
3
+ // - as_any_audit (audit unsafe `as any` casts)
4
+ // - no_swallowed_errors (catch blocks that only console.error)
5
+ const asAnyAudit = {
6
+ create(context) {
7
+ return {
8
+ TSAsExpression(node) {
9
+ const annotation = node.typeAnnotation;
10
+ if (annotation.type === "TSAnyKeyword" ||
11
+ (annotation.type === "TSTypeReference" &&
12
+ annotation.typeName?.type === "Identifier" &&
13
+ annotation.typeName.name === "any")) {
14
+ context.report({
15
+ node: annotation,
16
+ message: "Unsafe `as any` cast. Use a specific type, `as unknown`, or a type guard instead.",
17
+ });
18
+ }
19
+ },
20
+ };
21
+ },
22
+ };
23
+ const ALLOWED_ACTIONS = new Set(["throw", "setError", "toast", "reportError", "return"]);
24
+ function hasAllowedAction(body) {
25
+ if (!body || body.type !== "BlockStatement")
26
+ return false;
27
+ for (const stmt of body.body) {
28
+ if (stmt.type === "ThrowStatement")
29
+ return true;
30
+ if (stmt.type === "ReturnStatement")
31
+ return true;
32
+ if (stmt.type === "ExpressionStatement" && stmt.expression.type === "CallExpression") {
33
+ const callee = stmt.expression.callee;
34
+ // setError(...), reportError(...)
35
+ if (callee.type === "Identifier" && ALLOWED_ACTIONS.has(callee.name))
36
+ return true;
37
+ // toast.error(...)
38
+ if (callee.type === "MemberExpression" &&
39
+ callee.object.type === "Identifier" &&
40
+ ALLOWED_ACTIONS.has(callee.object.name)) {
41
+ return true;
42
+ }
43
+ }
44
+ }
45
+ return false;
46
+ }
47
+ function hasConsoleError(body) {
48
+ if (!body || body.type !== "BlockStatement")
49
+ return false;
50
+ for (const stmt of body.body) {
51
+ if (stmt.type === "ExpressionStatement" &&
52
+ stmt.expression.type === "CallExpression" &&
53
+ stmt.expression.callee.type === "MemberExpression" &&
54
+ stmt.expression.callee.object.type === "Identifier" &&
55
+ stmt.expression.callee.object.name === "console" &&
56
+ stmt.expression.callee.property.type === "Identifier" &&
57
+ stmt.expression.callee.property.name === "error") {
58
+ return true;
59
+ }
60
+ }
61
+ return false;
62
+ }
63
+ const noSwallowedErrors = {
64
+ create(context) {
65
+ return {
66
+ CatchClause(node) {
67
+ const body = node.body;
68
+ if (!body || body.type !== "BlockStatement")
69
+ return;
70
+ if (body.body.length === 0)
71
+ return;
72
+ if (hasConsoleError(body) && !hasAllowedAction(body)) {
73
+ context.report({
74
+ node,
75
+ message: "Catch block only logs with console.error — error is swallowed. Re-throw, return an error value, or use toast.error()/setError()/reportError().",
76
+ });
77
+ }
78
+ },
79
+ };
80
+ },
81
+ };
82
+ const plugin = {
83
+ meta: { name: "webpresso-safety" },
84
+ rules: {
85
+ "as-any-audit": asAnyAudit,
86
+ "no-swallowed-errors": noSwallowedErrors,
87
+ },
88
+ };
89
+ export default plugin;
@@ -0,0 +1,21 @@
1
+ declare const plugin: {
2
+ meta: {
3
+ name: string;
4
+ };
5
+ rules: {
6
+ "no-framework-imports": {
7
+ create(context: any): {
8
+ ImportDeclaration?: undefined;
9
+ ExportNamedDeclaration?: undefined;
10
+ ExportAllDeclaration?: undefined;
11
+ ImportExpression?: undefined;
12
+ } | {
13
+ ImportDeclaration(node: any): void;
14
+ ExportNamedDeclaration(node: any): void;
15
+ ExportAllDeclaration(node: any): void;
16
+ ImportExpression(node: any): void;
17
+ };
18
+ };
19
+ };
20
+ };
21
+ export default plugin;
@@ -0,0 +1,52 @@
1
+ // @ts-nocheck
2
+ // Foundation purity — prevents framework coupling in the foundation tier.
3
+ // Flags imports from hono, express, fastify, koa in packages/foundation/ files.
4
+ const FORBIDDEN_FRAMEWORKS = new Set(["hono", "express", "fastify", "koa"]);
5
+ function isForbiddenImport(source) {
6
+ return typeof source === "string" && FORBIDDEN_FRAMEWORKS.has(source.split("/")[0]);
7
+ }
8
+ function isFoundationFile(context) {
9
+ const filename = typeof context.getFilename === "function" ? context.getFilename() : context.filename;
10
+ return filename.replaceAll("\\", "/").includes("/packages/foundation/");
11
+ }
12
+ const noFrameworkImports = {
13
+ create(context) {
14
+ if (!isFoundationFile(context))
15
+ return {};
16
+ function checkImport(source, node) {
17
+ if (isForbiddenImport(source)) {
18
+ context.report({
19
+ node,
20
+ message: `Foundation packages must not depend on HTTP frameworks. Remove import from "${source}" — framework adapters belong in apps or feature packages.`,
21
+ });
22
+ }
23
+ }
24
+ return {
25
+ ImportDeclaration(node) {
26
+ checkImport(node.source.value, node.source);
27
+ },
28
+ ExportNamedDeclaration(node) {
29
+ if (!node.source)
30
+ return;
31
+ checkImport(node.source.value, node.source);
32
+ },
33
+ ExportAllDeclaration(node) {
34
+ if (!node.source)
35
+ return;
36
+ checkImport(node.source.value, node.source);
37
+ },
38
+ ImportExpression(node) {
39
+ if (node.source.type !== "Literal")
40
+ return;
41
+ checkImport(node.source.value, node.source);
42
+ },
43
+ };
44
+ },
45
+ };
46
+ const plugin = {
47
+ meta: { name: "webpresso-foundation-purity" },
48
+ rules: {
49
+ "no-framework-imports": noFrameworkImports,
50
+ },
51
+ };
52
+ export default plugin;
@@ -0,0 +1,23 @@
1
+ declare const plugin: {
2
+ meta: {
3
+ name: string;
4
+ };
5
+ rules: {
6
+ "no-singular-graphql-fields": {
7
+ create(context: any): {
8
+ TemplateLiteral(node: any): void;
9
+ Literal(node: any): void;
10
+ };
11
+ };
12
+ "no-inline-graphql-in-app": {
13
+ create(context: any): {
14
+ TaggedTemplateExpression?: undefined;
15
+ TemplateLiteral?: undefined;
16
+ } | {
17
+ TaggedTemplateExpression(node: any): void;
18
+ TemplateLiteral(node: any): void;
19
+ };
20
+ };
21
+ };
22
+ };
23
+ export default plugin;
@@ -0,0 +1,202 @@
1
+ // @ts-nocheck
2
+ // Webpresso GraphQL convention rules:
3
+ // - no-singular-graphql-fields: Prevents use of singular table names in GraphQL queries/mutations
4
+ //
5
+ // Hasura auto-generates GraphQL root fields from table names. All Webpresso tables use plural names
6
+ // (e.g., `users`, `organizations`, `projects`). Using singular names (e.g., `user(limit: 1)`)
7
+ // causes runtime errors: "field 'user' not found in type: 'query_root'"
8
+ //
9
+ // This rule scans template literals and string literals for known singular patterns and reports them.
10
+ import { isWebAppSurface, normalizeFilename } from "./path-roles.js";
11
+ // Mapping of singular table patterns to their correct plural/actual GraphQL field names.
12
+ // Format: [singular, correct, description]
13
+ const SINGULAR_TABLE_RULES = [
14
+ ["user", "users", "users"],
15
+ ["organization", "organizations", "organizations"],
16
+ ["member", "members", "members"],
17
+ ["project", "projects", "projects"],
18
+ ["entity", "meta_entities", "meta_entities"],
19
+ ];
20
+ // Build regex patterns for each singular table name.
21
+ // These detect GraphQL root field usage patterns:
22
+ // - Query fields: `user(`, `user {`, `user_by_pk`, `user_aggregate`
23
+ // - Insert mutations: `insert_user_one`, `insert_user(`
24
+ // - Update mutations: `update_user_by_pk`, `update_user(`
25
+ // - Delete mutations: `delete_user_by_pk`, `delete_user(`
26
+ function buildPatterns() {
27
+ const patterns = [];
28
+ for (const [singular, correct, label] of SINGULAR_TABLE_RULES) {
29
+ // Intentional internal-only dynamic regex: `singular` values come from the
30
+ // static SINGULAR_TABLE_RULES table above, not from user input.
31
+ // Query root fields: `user {`, `user_by_pk`, `user_aggregate`, `user(where:`, `user(limit:`
32
+ // Must NOT match plural forms (e.g., `users(`) or compound words (e.g., `user_id`)
33
+ // For `singular(`, requires GraphQL-like arg after `(` to avoid false positives
34
+ // on English text like "organization (RLS Block)"
35
+ patterns.push({
36
+ regex: new RegExp(`(?<![a-zA-Z_])${singular}(?:` +
37
+ `\\s*\\{` + // `user {` (field selection)
38
+ `|_by_pk` + // `user_by_pk`
39
+ `|_aggregate` + // `user_aggregate`
40
+ `|\\s*\\(\\s*(?:where|limit|offset|order_by|distinct_on|\\$)` + // `user(where:`, `user(limit:`, `user($var`
41
+ `)(?!s)`),
42
+ singular,
43
+ correct,
44
+ label,
45
+ type: "query",
46
+ });
47
+ // Insert mutations: `insert_user_one`, `insert_user(`
48
+ patterns.push({
49
+ regex: new RegExp(`insert_${singular}(?:_one|\\s*\\()`),
50
+ singular,
51
+ correct,
52
+ label,
53
+ type: "insert",
54
+ });
55
+ // Update mutations: `update_user_by_pk`, `update_user(`
56
+ patterns.push({
57
+ regex: new RegExp(`update_${singular}(?:_by_pk|\\s*\\()`),
58
+ singular,
59
+ correct,
60
+ label,
61
+ type: "update",
62
+ });
63
+ // Delete mutations: `delete_user_by_pk`, `delete_user(`
64
+ patterns.push({
65
+ regex: new RegExp(`delete_${singular}(?:_by_pk|\\s*\\()`),
66
+ singular,
67
+ correct,
68
+ label,
69
+ type: "delete",
70
+ });
71
+ }
72
+ return patterns;
73
+ }
74
+ const PATTERNS = buildPatterns();
75
+ const INLINE_GRAPHQL_EXEMPT_PATH_SEGMENTS = [
76
+ "/.webpresso/generated/",
77
+ "/packages/sdk/schema-engine/src/emitters/",
78
+ "/packages/feature/app-core/src/daemon/",
79
+ ];
80
+ function getFilename(context) {
81
+ if (typeof context.getFilename === "function") {
82
+ return normalizeFilename(context.getFilename());
83
+ }
84
+ return normalizeFilename(context.filename);
85
+ }
86
+ function isClientQuerySurface(filename) {
87
+ const normalized = normalizeFilename(filename);
88
+ if (!/\.tsx?$/.test(normalized))
89
+ return false;
90
+ if (INLINE_GRAPHQL_EXEMPT_PATH_SEGMENTS.some((segment) => normalized.includes(segment))) {
91
+ return false;
92
+ }
93
+ const isFeatureSurface = normalized.includes("/packages/feature/") && normalized.includes("/src/");
94
+ return isWebAppSurface(normalized) || isFeatureSurface;
95
+ }
96
+ function getNodeStart(node) {
97
+ if (Array.isArray(node?.range) && typeof node.range[0] === "number") {
98
+ return node.range[0];
99
+ }
100
+ return typeof node?.start === "number" ? node.start : null;
101
+ }
102
+ function isGraphqlCommentTagged(context, node) {
103
+ const sourceText = context.sourceCode?.getText?.();
104
+ const start = getNodeStart(node);
105
+ if (typeof sourceText !== "string" || typeof start !== "number") {
106
+ return false;
107
+ }
108
+ return /\/\*\s*GraphQL\s*\*\/\s*$/.test(sourceText.slice(Math.max(0, start - 40), start));
109
+ }
110
+ function isGqlTag(tag) {
111
+ if (!tag)
112
+ return false;
113
+ if (tag.type === "Identifier")
114
+ return tag.name === "gql";
115
+ return (tag.type === "MemberExpression" &&
116
+ !tag.computed &&
117
+ tag.property?.type === "Identifier" &&
118
+ tag.property.name === "gql");
119
+ }
120
+ function checkStringForSingularFields(context, node, text) {
121
+ // Quick pre-check: skip strings that don't look like GraphQL
122
+ // GraphQL queries contain `{` or `query` or `mutation` or `subscription`
123
+ if (!text.includes("{") &&
124
+ !text.includes("query") &&
125
+ !text.includes("mutation") &&
126
+ !text.includes("subscription")) {
127
+ return;
128
+ }
129
+ for (const pattern of PATTERNS) {
130
+ if (!pattern.regex.test(text))
131
+ continue;
132
+ const correctedField = pattern.type === "query"
133
+ ? pattern.correct
134
+ : pattern.type === "insert"
135
+ ? `insert_${pattern.correct}_one`
136
+ : pattern.type === "update"
137
+ ? `update_${pattern.correct}_by_pk`
138
+ : `delete_${pattern.correct}_by_pk`;
139
+ const matched = text.match(pattern.regex)?.[0] ?? `${pattern.singular}`;
140
+ context.report({
141
+ node,
142
+ message: `Singular GraphQL field name '${matched}' — Hasura uses plural table names. ` +
143
+ `Use '${correctedField}' instead. ` +
144
+ `All tables use plural names (e.g., users, organizations, projects, members, meta_entities).`,
145
+ });
146
+ // Report only the first match per string to avoid noise
147
+ return;
148
+ }
149
+ }
150
+ const noSingularGraphqlFields = {
151
+ create(context) {
152
+ return {
153
+ // Check template literals (most GraphQL queries use backticks)
154
+ TemplateLiteral(node) {
155
+ // Combine all quasis (static parts) of the template literal
156
+ const text = node.quasis.map((q) => q.value?.raw ?? q.value?.cooked ?? "").join("");
157
+ checkStringForSingularFields(context, node, text);
158
+ },
159
+ // Check regular string literals (some queries use single/double quotes)
160
+ Literal(node) {
161
+ if (typeof node.value !== "string")
162
+ return;
163
+ checkStringForSingularFields(context, node, node.value);
164
+ },
165
+ };
166
+ },
167
+ };
168
+ const noInlineGraphqlInApp = {
169
+ create(context) {
170
+ if (!isClientQuerySurface(getFilename(context))) {
171
+ return {};
172
+ }
173
+ function report(node) {
174
+ context.report({
175
+ node,
176
+ message: "Inline GraphQL is banned in client query surfaces. Use generated SDK operations or shared query option factories instead.",
177
+ });
178
+ }
179
+ return {
180
+ TaggedTemplateExpression(node) {
181
+ if (!isGqlTag(node.tag))
182
+ return;
183
+ report(node);
184
+ },
185
+ TemplateLiteral(node) {
186
+ if (node.parent?.type === "TaggedTemplateExpression")
187
+ return;
188
+ if (!isGraphqlCommentTagged(context, node))
189
+ return;
190
+ report(node);
191
+ },
192
+ };
193
+ },
194
+ };
195
+ const plugin = {
196
+ meta: { name: "webpresso-graphql" },
197
+ rules: {
198
+ "no-singular-graphql-fields": noSingularGraphqlFields,
199
+ "no-inline-graphql-in-app": noInlineGraphqlInApp,
200
+ },
201
+ };
202
+ export default plugin;
@@ -0,0 +1,38 @@
1
+ export { isAgentKitSeaTreeFile, isPlatformAppFile, isSdkPackageFile, normalizeFilename, } from "./path-roles.js";
2
+ /** Package imports forbidden in agent-kit SEA surfaces (schema + ui paint kits). */
3
+ export declare function isSeaForbiddenPackageImport(source: any): boolean;
4
+ export declare const SEA_FORBIDDEN_IMPORT_MESSAGE = "Agent-kit SEA surfaces (compose / wp_ui_*) must not import `@webpresso/schema` or `@webpresso/ui`. Use the two-tier block registry snapshot/project file and local Zod/HTML paint instead (offline / nondeterminism lock).";
5
+ declare const plugin: {
6
+ meta: {
7
+ name: string;
8
+ };
9
+ rules: {
10
+ "no-relative-parent-imports": {
11
+ create(context: any): {
12
+ ImportDeclaration(node: any): void;
13
+ ExportNamedDeclaration(node: any): void;
14
+ ExportAllDeclaration(node: any): void;
15
+ ImportExpression(node: any): void;
16
+ };
17
+ };
18
+ "no-src-path-imports": {
19
+ create(context: any): {
20
+ ImportDeclaration(node: any): void;
21
+ };
22
+ };
23
+ "no-relative-mock-paths": {
24
+ create(context: any): {
25
+ CallExpression(node: any): void;
26
+ };
27
+ };
28
+ "no-forbidden-package-imports": {
29
+ create(context: any): {
30
+ ImportDeclaration(node: any): void;
31
+ ExportNamedDeclaration(node: any): void;
32
+ ExportAllDeclaration(node: any): void;
33
+ ImportExpression(node: any): void;
34
+ };
35
+ };
36
+ };
37
+ };
38
+ export default plugin;