@fitzzero/quickdraw-lint 5.0.1 → 5.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/README.md CHANGED
@@ -47,7 +47,7 @@ correct code; each rule's file says what it leaves alone.
47
47
  | `no-nested-write` | a nested relation write (`data: { labels: { create } }`) through the tracked client (`db`, `tx`): only the parent row is tracked | everywhere |
48
48
  | `no-raw-sql-write` | `$executeRaw*`, and `$queryRaw*` running a write, in a function that does not record the rows with `ctx.touch(...)` (or `qd.collections.reset(...)`) | services, jobs, routes |
49
49
  | `no-manual-emit` | `.emit()` on the Socket.IO server or a socket (`io.to(room).emit`), and `qd:` strings, the framework's event and room names | services, jobs, routes |
50
- | `no-inline-auth-guard` | `if (!ctx.principal) throw ...` and its variants inside a `handler`; declare `access` instead | everywhere |
50
+ | `no-inline-auth-guard` | `if (!ctx.principal) throw ...` and its variants inside a `handler` (declare `access` instead), and a kind check there that throws, `if (ctx.principal.kind !== "user") throw ...` (declare `kinds` instead) | everywhere |
51
51
  | `no-unbounded-read` | `findMany` without `take` (a read by ids, `id` equal, `{ in }` or `{ equals }`, is bounded; `{ not }`, `{ notIn }` and `{ gt }` are not) | services |
52
52
  | `no-db-call-in-loop` | a database call per item of a `for`/`for...of`/`for...in` loop or `.map` callback, awaited or sent to `Promise.all` (not `while`, `createMany`, `in:` its own set, `tx` writes) | services, jobs, routes |
53
53
  | `no-emit-in-loop` | `ctx.rooms.emit`, `emitToUser` or a stream's `push` once per item to the same room, user or stream scope (fan-out is fine; batch with `pushMany`) | services, jobs, routes |
@@ -60,19 +60,23 @@ correct code; each rule's file says what it leaves alone.
60
60
  | `no-raw-socket` | `socket.emit`, `socket.on`, `socket.off` and the other raw Socket.IO calls: on a `socket` (or `x.socket`), on whatever `socket.io-client`'s `io()`, `connect()` or `Manager` made under any name (followed through variables and `this` fields), and with a `qd:` event name on any receiver | client code |
61
61
  | `no-v4-api` | every 4.x API 5.0 removed or moved; each message names the replacement | everywhere |
62
62
  | `prefer-kit` | a method written by hand that a kit implements (`get`, `list`, `create`, `search`, `share`, `adminList`, or `getTask`, `listTasks`, `createTask`, `updateTask`, `deleteTask` for model `"task"`; `remove` only on a membership model or beside another sharing method) in a `defineService` whose `model` is a string (or a `const` of one) and whose `methods` spread no kit (a spread variable or call counts as one), or that duplicates what a kit spread beside it serves (`crud.handlers`' `access` literal names, `search` for `search.handlers`, every admin method for `admin.handlers`); a `// quickdraw: hand-written because <reason>` comment above it keeps it quiet | everywhere but tests |
63
- | `no-todo-schema` | `todoSchema()`, the placeholder schema the 4.x migration (`@fitzzero/quickdraw-codemod`) leaves where a method had none: it validates nothing | everywhere |
63
+ | `no-todo-schema` | `todoSchema()`, the placeholder schema the 4.x migration (`@fitzzero/quickdraw-codemod`) leaves where a method had none: it validates nothing; as a `query()` or `mutation()` input without `keys`, it also hides the input's keys from `defineService`'s id-reach (rowless) check, and the message says so | everywhere |
64
+ | `require-describe` | a contract member without a `describe`: the contract (`defineContract`), each `query` and `mutation`, collection, stream, channel and event, written as an object literal (a spread or a definition built elsewhere is left alone), and a static `describe` under `minWords` words (default 3); the MCP bridge describes a method's tool with it and `quickdraw-docs` leads each section with it | everywhere but tests |
64
65
  | `no-unused-baseline` | an allowance in the baseline file that no violation uses any more (below) | baselined files |
65
66
  | `no-raw-button-strings`, `no-raw-tooltip-strings`, `no-raw-typography-strings` | raw strings in MUI `Button`, `Typography` and `Tooltip` titles (`oxlint.template.jsonc`) | `*.tsx`, `*.jsx` |
66
67
 
67
68
  The base config turns on all but the last three, at `error`, except
68
- `no-unused-baseline`, `no-todo-schema` and `prefer-kit`, which warn.
69
+ `no-unused-baseline`, `no-todo-schema`, `prefer-kit` and `require-describe`,
70
+ which warn. A warning never fails an upgrade's lint; once an app has no
71
+ reports left from one, it can set the rule to `"error"` in its own config
72
+ (`"quickdraw/require-describe": "error"`, say) so new code cannot add any.
69
73
  `oxlint.template.jsonc` extends the base and adds the last three.
70
74
 
71
75
  ### Which files a rule checks
72
76
 
73
77
  A rule about one layer checks only that layer's files. The defaults follow the
74
- template's layout. The rules scoped to services, jobs and routes, and
75
- `no-raw-socket`, skip tests (`__tests__/`, `test/`, `tests/`, `*.test.*`,
78
+ template's layout. The rules scoped to services, jobs and routes,
79
+ `no-raw-socket`, `prefer-kit` and `require-describe` skip tests (`__tests__/`, `test/`, `tests/`, `*.test.*`,
76
80
  `*.spec.*`), which seed, inspect and probe on purpose; the other rules check
77
81
  tests too.
78
82
 
@@ -105,7 +109,8 @@ that look at database calls; `modules` (specifiers of the untracked client's
105
109
  modules) on `no-untracked-write`; `emitters` on `no-manual-emit`; `sockets`,
106
110
  `allowedEvents` and `allowedPrefixes` on `no-raw-socket`; `clients` (the typed
107
111
  client's names, `["qd"]`) on `no-untyped-client` and `no-manual-refetch`;
108
- `shared` and `allow` (`{ "<service>": ["<service>/<file>"] }`) on
112
+ `minWords` (the fewest words a static describe may have, default 3) on
113
+ `require-describe`; `shared` and `allow` (`{ "<service>": ["<service>/<file>"] }`) on
109
114
  `no-cross-service-internal-imports`. Each rule's options are in its schema,
110
115
  so a misspelled option fails oxlint at startup.
111
116
 
package/oxlint.base.jsonc CHANGED
@@ -116,6 +116,9 @@
116
116
  "quickdraw/no-v4-api": "error",
117
117
  // The migration's placeholder schema, until a real schema replaces it.
118
118
  "quickdraw/no-todo-schema": "warn",
119
+ // A describe on every contract member: MCP tool descriptions and the API docs.
120
+ // A warning so an upgrade never breaks lint; set it to "error" once the app has caught up.
121
+ "quickdraw/require-describe": "warn",
119
122
  },
120
123
  "overrides": [
121
124
  // Client/React code: relax server-oriented strictness.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitzzero/quickdraw-lint",
3
- "version": "5.0.1",
3
+ "version": "5.1.1",
4
4
  "description": "oxlint plugin, base config and baseline command for quickdraw apps",
5
5
  "keywords": [
6
6
  "lint",
package/plugin/index.mjs CHANGED
@@ -29,6 +29,7 @@ import noUntypedClient from "./rules/no-untyped-client.mjs";
29
29
  import noUnusedBaseline from "./rules/no-unused-baseline.mjs";
30
30
  import noV4Api from "./rules/no-v4-api.mjs";
31
31
  import preferKit from "./rules/prefer-kit.mjs";
32
+ import requireDescribe from "./rules/require-describe.mjs";
32
33
 
33
34
  const rules = {
34
35
  // Tracked writes
@@ -54,6 +55,8 @@ const rules = {
54
55
  "no-raw-socket": noRawSocket,
55
56
  // The kits
56
57
  "prefer-kit": preferKit,
58
+ // Documentation
59
+ "require-describe": requireDescribe,
57
60
  // Migration from 4.x
58
61
  "no-v4-api": noV4Api,
59
62
  "no-todo-schema": noTodoSchema,
@@ -6,8 +6,16 @@
6
6
  // `principal`, either side of `||`). A guard that returns instead of
7
7
  // throwing (a public method answering anonymous callers differently) is not
8
8
  // reported.
9
+ //
10
+ // The kinds of principal that may call are declared beside `access`, as
11
+ // `kinds` (section 4.1), so it also reports a guard on the principal's kind
12
+ // that throws: `if (ctx.principal.kind !== "user") throw ...`, a comparison
13
+ // with a kind either way round, `!KINDS.includes(ctx.principal.kind)`, either
14
+ // side of `||`. A kind tested with `&&` beside something else (agents may
15
+ // not set this status) is a check `kinds` cannot make, and a `"public"`
16
+ // method takes no `kinds`, so neither is reported.
9
17
 
10
- import { chainNames, isFunction, keyName, unwrap } from "../lib/ast.mjs";
18
+ import { chainNames, getProperty, isFunction, keyName, memberName, unwrap } from "../lib/ast.mjs";
11
19
 
12
20
  /** Whether `node` reads the principal: `ctx.principal`, `principal`, or their `userId`. */
13
21
  function isPrincipal(node) {
@@ -28,6 +36,49 @@ function isNullish(node) {
28
36
  );
29
37
  }
30
38
 
39
+ /** Whether `node` reads the principal's kind: `ctx.principal.kind` or `principal.kind`. */
40
+ function isKind(node) {
41
+ const value = unwrap(node);
42
+ if (value.type !== "MemberExpression") {
43
+ return false;
44
+ }
45
+ const names = chainNames(value);
46
+ const at = names[0] === "ctx" ? 1 : 0;
47
+ return names.length - at === 2 && names[at] === "principal" && names[at + 1] === "kind";
48
+ }
49
+
50
+ function isString(node) {
51
+ const value = unwrap(node);
52
+ return value.type === "Literal" && typeof value.value === "string";
53
+ }
54
+
55
+ /** Whether a condition tests the principal's kind and nothing it must hold beside it. */
56
+ function checksKind(test) {
57
+ const node = unwrap(test);
58
+ switch (node.type) {
59
+ case "LogicalExpression":
60
+ return node.operator === "||" && (checksKind(node.left) || checksKind(node.right));
61
+ case "UnaryExpression":
62
+ return node.operator === "!" && checksKind(node.argument);
63
+ case "BinaryExpression":
64
+ return (
65
+ ["==", "===", "!=", "!=="].includes(node.operator) &&
66
+ ((isKind(node.left) && isString(node.right)) || (isString(node.left) && isKind(node.right)))
67
+ );
68
+ case "CallExpression": {
69
+ const callee = unwrap(node.callee);
70
+ return (
71
+ callee.type === "MemberExpression" &&
72
+ memberName(callee) === "includes" &&
73
+ node.arguments.length === 1 &&
74
+ isKind(node.arguments[0])
75
+ );
76
+ }
77
+ default:
78
+ return false;
79
+ }
80
+ }
81
+
31
82
  /** Whether a condition is true when there is no principal. */
32
83
  function checksMissingPrincipal(test) {
33
84
  const node = unwrap(test);
@@ -57,19 +108,38 @@ function throwsFirst(statement) {
57
108
  return statement.type === "BlockStatement" && statement.body[0]?.type === "ThrowStatement";
58
109
  }
59
110
 
60
- /** Whether `node` runs inside a method's `handler`. */
61
- function insideHandler(context, node) {
62
- return context.sourceCode.getAncestors(node).some((ancestor) => {
63
- if (!isFunction(ancestor)) {
64
- return false;
65
- }
111
+ /** The `handler` property (or method) whose function `node` runs inside, or `undefined`. */
112
+ function handlerOf(context, node) {
113
+ for (const ancestor of context.sourceCode.getAncestors(node)) {
66
114
  const holder = ancestor.parent;
67
- return (
115
+ if (
116
+ isFunction(ancestor) &&
68
117
  (holder?.type === "Property" || holder?.type === "MethodDefinition") &&
69
118
  holder.value === ancestor &&
70
119
  keyName(holder) === "handler"
71
- );
72
- });
120
+ ) {
121
+ return holder;
122
+ }
123
+ }
124
+ return undefined;
125
+ }
126
+
127
+ /** Whether a method entry declares `access: "public"`, beside its `handler`. */
128
+ function isPublicMethod(handler) {
129
+ const entry = handler.parent;
130
+ if (entry?.type !== "ObjectExpression") {
131
+ return false;
132
+ }
133
+ const access = getProperty(entry, "access");
134
+ return access !== undefined && unwrap(access.value).value === "public";
135
+ }
136
+
137
+ /** Which guard a condition is, if any: a missing principal, or the principal's kind. */
138
+ function guardOf(test) {
139
+ if (checksMissingPrincipal(test)) {
140
+ return "inlineGuard";
141
+ }
142
+ return checksKind(test) ? "kindGuard" : undefined;
73
143
  }
74
144
 
75
145
  /** @type {import('eslint').Rule.RuleModule} */
@@ -78,25 +148,30 @@ export default {
78
148
  type: "suggestion",
79
149
  docs: {
80
150
  description:
81
- "Disallow checking `ctx.principal` by hand in a handler: declare who may call in the method's `access`.",
151
+ "Disallow checking `ctx.principal` or its kind by hand in a handler: declare who may call in the method's `access` and `kinds`.",
82
152
  },
83
153
  messages: {
84
154
  inlineGuard:
85
155
  "Don't check `ctx.principal` by hand: declare it in the method's `access` (`\"authenticated\"`, `{ service }`, `{ entry }`, `{ scope, of, id }` or `custom(fn)`). " +
86
156
  "The dispatcher then refuses anonymous callers with `UNAUTHENTICATED` before the handler runs, on every transport, and `ctx.principal` is typed as present.",
157
+ kindGuard:
158
+ 'Don\'t check `ctx.principal.kind` by hand: declare the kinds that may call beside `access`, as `kinds: ["user"]` on the method, on its service, or on `initQuickdraw` for every service. ' +
159
+ "The dispatcher then refuses every other kind with `FORBIDDEN` before the handler runs, on every transport and subscription, whatever its grants.",
87
160
  },
88
161
  schema: [],
89
162
  },
90
163
  create(context) {
91
164
  return {
92
165
  IfStatement(node) {
93
- if (
94
- checksMissingPrincipal(node.test) &&
95
- throwsFirst(node.consequent) &&
96
- insideHandler(context, node)
97
- ) {
98
- context.report({ node: node.test, messageId: "inlineGuard" });
166
+ const messageId = guardOf(node.test);
167
+ if (messageId === undefined || !throwsFirst(node.consequent)) {
168
+ return;
169
+ }
170
+ const handler = handlerOf(context, node);
171
+ if (handler === undefined || (messageId === "kindGuard" && isPublicMethod(handler))) {
172
+ return;
99
173
  }
174
+ context.report({ node: node.test, messageId });
100
175
  },
101
176
  };
102
177
  },
@@ -5,11 +5,18 @@
5
5
  // schema replaces it. Found by shape: a call of `todoSchema` imported from
6
6
  // `@fitzzero/quickdraw-core` under any local name, or of a namespace import's
7
7
  // `todoSchema` member.
8
+ //
9
+ // A placeholder without `keys` that is the `input` of `query()` or
10
+ // `mutation()` gets a second message: its JSON Schema names no keys, so
11
+ // defineService's id-reach (rowless) check cannot see an `id` in it and
12
+ // refuses nothing. Whether the method's access form checks a row is in the
13
+ // service file, which this rule does not read, so it says so for every form.
8
14
 
9
- import { memberName, unwrap } from "../lib/ast.mjs";
15
+ import { getProperty, hasSpread, memberName, unwrap } from "../lib/ast.mjs";
10
16
 
11
17
  const CORE = "@fitzzero/quickdraw-core";
12
18
  const NAME = "todoSchema";
19
+ const METHODS = new Set(["query", "mutation"]);
13
20
 
14
21
  function importedName(specifier) {
15
22
  return specifier.imported.type === "Identifier"
@@ -17,30 +24,81 @@ function importedName(specifier) {
17
24
  : specifier.imported.value;
18
25
  }
19
26
 
20
- /** Records the local names `todoSchema` and the core namespace go by in this file. */
27
+ /**
28
+ * Records the local names `todoSchema`, `query`, `mutation` and the core
29
+ * namespace go by in this file. `names` maps a local name to the imported one.
30
+ */
21
31
  function collectImport(node, names, namespaces) {
22
32
  if (node.source.value !== CORE) {
23
33
  return;
24
34
  }
25
35
  for (const specifier of node.specifiers) {
26
- if (specifier.type === "ImportSpecifier" && importedName(specifier) === NAME) {
27
- names.add(specifier.local.name);
36
+ if (specifier.type === "ImportSpecifier") {
37
+ const imported = importedName(specifier);
38
+ if (imported === NAME || METHODS.has(imported)) {
39
+ names.set(specifier.local.name, imported);
40
+ }
28
41
  } else if (specifier.type === "ImportNamespaceSpecifier") {
29
42
  namespaces.add(specifier.local.name);
30
43
  }
31
44
  }
32
45
  }
33
46
 
34
- /** Whether `callee` is `todoSchema` (by its local name) or `namespace.todoSchema`. */
35
- function isTodoSchema(callee, names, namespaces) {
47
+ /** The core export `callee` calls (by its local name or as `namespace.name`), if any. */
48
+ function coreName(callee, names, namespaces) {
36
49
  if (callee.type === "Identifier") {
37
- return names.has(callee.name);
50
+ return names.get(callee.name);
38
51
  }
39
- if (callee.type !== "MemberExpression" || memberName(callee) !== NAME) {
40
- return false;
52
+ if (callee.type !== "MemberExpression") {
53
+ return undefined;
41
54
  }
42
55
  const object = unwrap(callee.object);
43
- return object.type === "Identifier" && namespaces.has(object.name);
56
+ return object.type === "Identifier" && namespaces.has(object.name)
57
+ ? memberName(callee)
58
+ : undefined;
59
+ }
60
+
61
+ /**
62
+ * Whether a `todoSchema(...)` call surely names no keys: no options, or an
63
+ * options literal without `keys` (or with an empty list). Options it cannot
64
+ * read (a variable, a spread) may name some, so they do not count.
65
+ */
66
+ function isKeyless(call) {
67
+ const options = call.arguments[0];
68
+ if (options === undefined) {
69
+ return true;
70
+ }
71
+ const object = unwrap(options);
72
+ if (object.type !== "ObjectExpression" || hasSpread(object)) {
73
+ return false;
74
+ }
75
+ const keys = getProperty(object, "keys");
76
+ if (keys === undefined) {
77
+ return true;
78
+ }
79
+ const list = unwrap(keys.value);
80
+ return list.type === "ArrayExpression" && list.elements.length === 0;
81
+ }
82
+
83
+ /** Whether `call` (through any wrapping assertion) is the `input` of a core `query()` or `mutation()`. */
84
+ function isMethodInput(call, names, namespaces) {
85
+ let value = call;
86
+ while (value.parent !== null && value.parent !== undefined && unwrap(value.parent) === call) {
87
+ value = value.parent;
88
+ }
89
+ const property = value.parent;
90
+ if (property?.type !== "Property" || property.value !== value) {
91
+ return false;
92
+ }
93
+ if (getProperty(property.parent, "input") !== property) {
94
+ return false;
95
+ }
96
+ const method = property.parent.parent;
97
+ return (
98
+ method?.type === "CallExpression" &&
99
+ method.arguments[0] === property.parent &&
100
+ METHODS.has(coreName(unwrap(method.callee), names, namespaces))
101
+ );
44
102
  }
45
103
 
46
104
  /** @type {import('eslint').Rule.RuleModule} */
@@ -55,19 +113,26 @@ export default {
55
113
  todoSchema:
56
114
  "`todoSchema()` is a placeholder left by the 4.x migration: it validates nothing, so any value passes as its type. " +
57
115
  "Replace it with a real schema (Zod 4.2 or later where JSON Schema is read: MCP tools, admin metadata, projection keys).",
116
+ keylessInput:
117
+ "`todoSchema()` is a placeholder left by the 4.x migration: it validates nothing, so any value passes as its type. " +
118
+ "Without `keys`, it also hides this input's keys from defineService's id-reach (rowless) check, " +
119
+ "which refuses an `id` input under an access form that checks no row; give it keys " +
120
+ '(`todoSchema<T>({ keys: ["id", ...] })`) until a real schema (Zod 4.2 or later) replaces it.',
58
121
  },
59
122
  schema: [],
60
123
  },
61
124
  create(context) {
62
- const names = new Set();
125
+ const names = new Map();
63
126
  const namespaces = new Set();
64
127
  return {
65
128
  ImportDeclaration: (node) => collectImport(node, names, namespaces),
66
129
  CallExpression(node) {
67
130
  const callee = unwrap(node.callee);
68
- if (isTodoSchema(callee, names, namespaces)) {
69
- context.report({ node: callee, messageId: "todoSchema" });
131
+ if (coreName(callee, names, namespaces) !== NAME) {
132
+ return;
70
133
  }
134
+ const keyless = isKeyless(node) && isMethodInput(node, names, namespaces);
135
+ context.report({ node: callee, messageId: keyless ? "keylessInput" : "todoSchema" });
71
136
  },
72
137
  };
73
138
  },
@@ -0,0 +1,195 @@
1
+ // Every contract member says what it is for: a `describe` on each
2
+ // `query(...)` and `mutation(...)`, on the contract itself and on each of its
3
+ // collections, streams, channels and events. The MCP bridge uses a method's
4
+ // describe as its tool's description (without one an agent sees only
5
+ // `service.method (query)`), and `quickdraw-docs` leads each section of the
6
+ // API docs with it. The 4.x codemod writes none, so a migrated contract has
7
+ // one report per member until someone writes them; a warning in the base
8
+ // config, `"error"` once an app has caught up.
9
+ //
10
+ // Found by shape: a call of `query`, `mutation` or `defineContract` imported
11
+ // from `@fitzzero/quickdraw-core` under any local name, or of a namespace
12
+ // import's member. Only object literals are checked: a spread (a kit's
13
+ // contract half supplies its own describes, and so may a shared options
14
+ // object) or a definition built elsewhere is left alone, and so is a
15
+ // describe that is not a static string. A static describe needs `minWords`
16
+ // words (default 3, the length of the kits' shortest). Test files are not
17
+ // checked.
18
+
19
+ import { keyName, memberName, staticString, unwrap } from "../lib/ast.mjs";
20
+ import { FILE_OPTIONS, TEST_FILES, inScope } from "../lib/files.mjs";
21
+
22
+ const CORE = "@fitzzero/quickdraw-core";
23
+ const METHODS = new Set(["query", "mutation"]);
24
+ const CONTRACT = "defineContract";
25
+ const NAMES = new Set([...METHODS, CONTRACT]);
26
+ /** The contract's members besides its methods, and what one of each is called in a message. */
27
+ const MEMBERS = {
28
+ collections: "collection",
29
+ streams: "stream",
30
+ channels: "channel",
31
+ events: "event",
32
+ };
33
+ const DEFAULT_MIN_WORDS = 3;
34
+
35
+ function importedName(specifier) {
36
+ return specifier.imported.type === "Identifier"
37
+ ? specifier.imported.name
38
+ : specifier.imported.value;
39
+ }
40
+
41
+ /** Records the local names of `query`, `mutation` and `defineContract`, and of core namespaces. */
42
+ function collectImport(node, locals, namespaces) {
43
+ if (node.source.value !== CORE) {
44
+ return;
45
+ }
46
+ for (const specifier of node.specifiers) {
47
+ if (specifier.type === "ImportSpecifier" && NAMES.has(importedName(specifier))) {
48
+ locals.set(specifier.local.name, importedName(specifier));
49
+ } else if (specifier.type === "ImportNamespaceSpecifier") {
50
+ namespaces.add(specifier.local.name);
51
+ }
52
+ }
53
+ }
54
+
55
+ /** Which of the core's builders `callee` calls, if any. */
56
+ function builderOf(callee, locals, namespaces) {
57
+ if (callee.type === "Identifier") {
58
+ return locals.get(callee.name);
59
+ }
60
+ if (callee.type !== "MemberExpression") {
61
+ return undefined;
62
+ }
63
+ const name = memberName(callee);
64
+ const object = unwrap(callee.object);
65
+ return NAMES.has(name) && object.type === "Identifier" && namespaces.has(object.name)
66
+ ? name
67
+ : undefined;
68
+ }
69
+
70
+ function wordCount(text) {
71
+ return text.split(/\s+/).filter((word) => word !== "").length;
72
+ }
73
+
74
+ /**
75
+ * Reports `object`, the definition of `member`, when it has no `describe`
76
+ * (and spreads nothing that could hold one) or a static one that is too
77
+ * short. `at` is the node a missing describe is reported on.
78
+ */
79
+ function checkDefinition(context, object, member, at, minWords) {
80
+ let describe;
81
+ for (const property of object.properties) {
82
+ if (property.type === "SpreadElement") {
83
+ if (describe === undefined) {
84
+ describe = null;
85
+ }
86
+ } else if (keyName(property) === "describe") {
87
+ describe = property;
88
+ }
89
+ }
90
+ if (describe === undefined) {
91
+ context.report({ node: at, messageId: "missing", data: { member } });
92
+ return;
93
+ }
94
+ const text = describe === null ? undefined : staticString(describe.value);
95
+ if (text !== undefined && wordCount(text) < minWords) {
96
+ context.report({
97
+ node: describe.value,
98
+ messageId: "short",
99
+ data: { member, count: String(wordCount(text)), minWords: String(minWords) },
100
+ });
101
+ }
102
+ }
103
+
104
+ /** The object literal `node` is, through parentheses and type assertions, if it is one. */
105
+ function objectLiteral(node) {
106
+ const value = unwrap(node);
107
+ return value?.type === "ObjectExpression" ? value : undefined;
108
+ }
109
+
110
+ /** A method call's name: the key it is written under (`get: query(...)`), if any. */
111
+ function methodLabel(call, kind) {
112
+ const parent = call.parent;
113
+ const name = parent?.type === "Property" && parent.value === call ? keyName(parent) : undefined;
114
+ return name === undefined ? `this ${kind}` : `method "${name}"`;
115
+ }
116
+
117
+ function checkContract(context, call, minWords) {
118
+ const definition = objectLiteral(call.arguments[1]);
119
+ if (definition === undefined) {
120
+ return;
121
+ }
122
+ const name = staticString(call.arguments[0]);
123
+ checkDefinition(
124
+ context,
125
+ definition,
126
+ name === undefined ? "this contract" : `contract "${name}"`,
127
+ call.callee,
128
+ minWords,
129
+ );
130
+ for (const property of definition.properties) {
131
+ const kind = property.type === "Property" ? MEMBERS[keyName(property)] : undefined;
132
+ const members = kind === undefined ? undefined : objectLiteral(property.value);
133
+ for (const entry of members?.properties ?? []) {
134
+ const memberDefinition = entry.type === "Property" ? objectLiteral(entry.value) : undefined;
135
+ const entryName = entry.type === "Property" ? keyName(entry) : undefined;
136
+ if (memberDefinition !== undefined && entryName !== undefined) {
137
+ checkDefinition(context, memberDefinition, `${kind} "${entryName}"`, entry.key, minWords);
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
+ /** @type {import('eslint').Rule.RuleModule} */
144
+ export default {
145
+ meta: {
146
+ type: "suggestion",
147
+ docs: {
148
+ description:
149
+ "Require a `describe` on every contract member: the contract, its methods, collections, streams, channels and events.",
150
+ },
151
+ messages: {
152
+ missing:
153
+ "{{ member }} has no `describe`. Say what it is for in a sentence or two: the MCP bridge uses a method's describe as its tool's description, and quickdraw-docs leads the member's section with it.",
154
+ short:
155
+ "{{ member }}'s `describe` has {{ count }} word(s); write at least {{ minWords }}, a sentence an agent can act on.",
156
+ },
157
+ schema: [
158
+ {
159
+ type: "object",
160
+ properties: {
161
+ ...FILE_OPTIONS,
162
+ minWords: {
163
+ type: "integer",
164
+ minimum: 1,
165
+ description: `The fewest words a static describe may have (default ${DEFAULT_MIN_WORDS}).`,
166
+ },
167
+ },
168
+ additionalProperties: false,
169
+ },
170
+ ],
171
+ },
172
+ create(context) {
173
+ const options = context.options[0] ?? {};
174
+ if (!inScope(context, options, { ignore: TEST_FILES })) {
175
+ return {};
176
+ }
177
+ const minWords = options.minWords ?? DEFAULT_MIN_WORDS;
178
+ const locals = new Map();
179
+ const namespaces = new Set();
180
+ return {
181
+ ImportDeclaration: (node) => collectImport(node, locals, namespaces),
182
+ CallExpression(node) {
183
+ const builder = builderOf(unwrap(node.callee), locals, namespaces);
184
+ if (builder === CONTRACT) {
185
+ checkContract(context, node, minWords);
186
+ } else if (METHODS.has(builder)) {
187
+ const definition = objectLiteral(node.arguments[0]);
188
+ if (definition !== undefined) {
189
+ checkDefinition(context, definition, methodLabel(node, builder), node.callee, minWords);
190
+ }
191
+ }
192
+ },
193
+ };
194
+ },
195
+ };