@jskit-ai/jskit-catalog 0.2.45 → 0.2.47

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.
@@ -1,8 +1,11 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { ROOT, lintIgnores, sourceFiles } from "./data-access-files.mjs";
3
+ import { ROOT, lintIgnores, sourceFiles, vueFiles } from "./data-access-files.mjs";
4
4
  import { toPosixPath } from "./files.mjs";
5
- import { blankJsComments, lineIndex } from "./material3-source.mjs";
5
+ import {
6
+ assignedVariable, blank, closingOffset, enclosingObject, identifierAt, keyValue, nextCodeOffset, objectArgument, objectAt,
7
+ objectKeys, readSourceFile, readValue, topLevelKeys
8
+ } from "./data-access-source.mjs";
6
9
 
7
10
  // The data part of plain `jskit check`, read from source, never run. It backs up what the lint rules cannot see across
8
11
  // files: every table has exactly one table resource (`defineTableResource({ name, tableName })`) across the app and its
@@ -15,8 +18,10 @@ import { blankJsComments, lineIndex } from "./material3-source.mjs";
15
18
  // too; an eslint-disable or inline eslint config that turns off a data-access rule, or every rule, fails in any file
16
19
  // but the client's; and so does an app file the app's own ESLint config lints without every data-access rule as an
17
20
  // error, or ignores though it is server code. Whatever else that config ignores, no lint reads, so neither does this
18
- // check: it lists what it skipped. As information, it lists the fields of each app table resource that its CRUD contracts
19
- // never name, which no API shows or accepts.
21
+ // check: it lists what it skipped. Every app table resource a lookup field picks from or a CRUD list screen shows defines
22
+ // searchSchema.q, on the resource or its owns entry, since the picker and the list's Search box send what the person
23
+ // types as q. As information, it lists the fields of each app table resource that its CRUD contracts never name, which
24
+ // no API shows or accepts.
20
25
 
21
26
  const GUIDE = "https://mobily-enterprises.github.io/jskit-ai/guide/framework/data-access";
22
27
  const SECTIONS = Object.freeze({
@@ -27,7 +32,8 @@ const SECTIONS = Object.freeze({
27
32
  "data/runtime-capability": "the-resource-is-the-only-door",
28
33
  "data/lint-disabled": "hard-rules",
29
34
  "data/lint-config": "hard-rules",
30
- "data/unnamed-fields": "layer-2-the-crud-surface"
35
+ "data/unnamed-fields": "layer-2-the-crud-surface",
36
+ "data/search-q": "narrow-in-the-query"
31
37
  });
32
38
  // What each rule asks, said once above its findings in the report.
33
39
  const RULE_EXPLANATIONS = Object.freeze({
@@ -41,6 +47,9 @@ const RULE_EXPLANATIONS = Object.freeze({
41
47
  "takes any other one once the baseline's systemScopes lists it with its reason.",
42
48
  "data/lint-disabled": "neither eslint-disable nor an inline eslint config comment can turn off a data-access rule, and a " +
43
49
  "disable that names no rule turns them all off. Change the code, or name the rules the directive turns off.",
50
+ "data/search-q": "a lookup field's picker and a CRUD list screen's Search box send what the person types as q, so the " +
51
+ "table resource they read defines searchSchema.q (on the resource or its owns entry), searching the fields a person " +
52
+ "would type; without it, every search fails. Searching the rows a page loaded instead misses every row past it.",
44
53
  "data/lint-config": "the app's ESLint config makes every data-access rule an error in each server file, and ignores no " +
45
54
  "server code; a config that turns one off, lowers it or ignores the file hides that code from it. Put the rule back, or, " +
46
55
  "once the module's whole job is data access, list { \"file\", \"rule\" } in the baseline's \"infrastructure\"."
@@ -78,7 +87,7 @@ const CLIENT_FILE = new RegExp(`^${ROOT}src/client/`, "u");
78
87
  // tests, migrations, seeds and build output at a root, seed scripts, and test files.
79
88
  const LINT_EXEMPT = new RegExp(`^${ROOT}(?:(?:src/client|src/test|test|tests|__tests__|e2e|test-support|migrations|seeds?|` +
80
89
  "dist|build|coverage)/|(?:(?:scripts|bin)/)?seed[^/]*\\.[cm]?js$)|\\.(?:test|spec|vitest)\\.[^/]*$", "u");
81
- const MARKERS = new RegExp(`defineTableResource|defineCrudContract|systemScope|systemContext|readable-check: allow|eslint|${CAPABILITY_NAMES}`, "iu");
90
+ const MARKERS = new RegExp(`defineTableResource|defineCrudContract|systemScope|systemContext|readable-check: allow|eslint|lookup|searchSchema|useCrudListScreen|${CAPABILITY_NAMES}`, "iu");
82
91
  const TABLE_RESOURCE_CALL = /\bdefineTableResource\s*\(/gu;
83
92
  const TABLE_RESOURCE_MARKER = /\bdefineTableResource\s*\(/u;
84
93
  // A CRUD contract on a table resource named by its variable: `defineCrudContract(receivalResource, { ... })`.
@@ -96,134 +105,6 @@ const FEATURE_CALL = /\bdefine(?:Crud)?Feature\s*\(/gu;
96
105
  const SYSTEM_CONTEXT_CALL = /\bsystemContext\s*\(/gu;
97
106
  const INFRASTRUCTURE_EXCUSE = /readable-check: allow (?:jskit\/)?(no-direct-knex|no-transaction-reader|no-hand-scope|sql-literal) - (infrastructure:.*?)\s*(?:\*\/)?\s*$/u;
98
107
 
99
- const blank = (text) => text.replace(/[^\n]/gu, " ");
100
-
101
- // Code with its comments and the contents of its strings and templates blanked to spaces, every offset kept: calls
102
- // and keys are found in it, values are read from the code at the same offsets.
103
- function maskStrings(code) {
104
- let output = "";
105
- let index = 0;
106
- while (index < code.length) {
107
- const quote = code[index];
108
- if (quote !== "\"" && quote !== "'" && quote !== "`") {
109
- output += quote;
110
- index += 1;
111
- continue;
112
- }
113
- let end = index + 1;
114
- while (end < code.length && code[end] !== quote && (quote === "`" || code[end] !== "\n")) {
115
- end += code[end] === "\\" ? 2 : 1;
116
- }
117
- output += quote + blank(code.slice(index + 1, end)) + code.slice(end, end + 1);
118
- index = end + 1;
119
- }
120
- return output;
121
- }
122
-
123
- // The offset just past the bracket that closes the one at `start`, in masked code; -1 when it never closes.
124
- function closingOffset(masked, start) {
125
- let depth = 0;
126
- for (let index = start; index < masked.length; index += 1) {
127
- if ("([{".includes(masked[index])) depth += 1;
128
- else if (")]}".includes(masked[index]) && --depth === 0) return index + 1;
129
- }
130
- return -1;
131
- }
132
-
133
- const nextCodeOffset = (text, offset) => offset + (/^\s*/u.exec(text.slice(offset))?.[0].length || 0);
134
-
135
- // The string a top-level `const NAME = "..."` gives, or null.
136
- function constantValue(code, name) {
137
- const match = new RegExp(`(?:^|[\\s;])const\\s+${name}\\s*=\\s*(["'\`])((?:\\\\.|(?!\\1)[^\\\\\\n])*)\\1\\s*[;,\\n]`, "u").exec(code);
138
- return match && !(match[1] === "`" && match[2].includes("${")) ? match[2] : null;
139
- }
140
-
141
- /**
142
- * The value written at an offset, read up to the comma or bracket that ends it: { text } for a string literal, a
143
- * template without expressions, or a const holding one; { text: null } for anything else.
144
- */
145
- function readValue(code, masked, offset) {
146
- const start = nextCodeOffset(code, offset);
147
- const quote = code[start];
148
- if (quote === "\"" || quote === "'" || quote === "`") {
149
- let end = start + 1;
150
- while (end < code.length && code[end] !== quote) end += code[end] === "\\" ? 2 : 1;
151
- const after = masked[nextCodeOffset(masked, end + 1)];
152
- const text = code.slice(start + 1, end);
153
- const whole = after === "," || after === "}" || after === ")";
154
- return { text: whole && !(quote === "`" && text.includes("${")) ? text.replace(/\\(.)/gu, "$1") : null };
155
- }
156
- const identifier = /^[A-Za-z_$][\w$]*(?=\s*[,})])/u.exec(code.slice(start));
157
- return { text: identifier ? constantValue(code, identifier[0]) : null };
158
- }
159
-
160
- // The top-level keys of the object literal between `start` and `end`, in masked code: for each wanted key, the offset
161
- // just past its colon, or "shorthand" for `{ name }`.
162
- function topLevelKeys(masked, start, end, names) {
163
- let depth = 0;
164
- let top = "";
165
- for (let index = start; index < end; index += 1) {
166
- const char = masked[index];
167
- if ("([{".includes(char)) depth += 1;
168
- top += depth === 1 || char === "\n" ? char : " ";
169
- if (")]}".includes(char)) depth -= 1;
170
- }
171
- const keys = new Map();
172
- for (const match of top.matchAll(/(?:^|[{,])\s*([A-Za-z_$][\w$]*)\s*(:|(?=[,}]))/gu)) {
173
- const [whole, key, colon] = match;
174
- if (names.includes(key) && !keys.has(key)) keys.set(key, colon ? start + match.index + whole.length : "shorthand");
175
- }
176
- return keys;
177
- }
178
-
179
- // The object literal a call at `callOffset` takes as its first argument: { start, end } in masked code, or null when the
180
- // call is a declaration (`function defineFeature(`) or its first argument is anything else.
181
- function objectArgument(masked, callOffset, matchLength) {
182
- if (/function\s*\*?\s*$/u.test(masked.slice(Math.max(0, callOffset - 20), callOffset))) return null;
183
- const start = nextCodeOffset(masked, callOffset + matchLength);
184
- if (masked[start] !== "{") return null;
185
- const end = closingOffset(masked, start);
186
- return end < 0 ? null : { start, end };
187
- }
188
-
189
- // The value of one key of an object literal, as readValue reads it; undefined when the object has no such key.
190
- function keyValue(code, masked, keys, key) {
191
- const at = keys.get(key);
192
- if (at === undefined) return undefined;
193
- return at === "shorthand" ? { text: constantValue(code, key) } : readValue(code, masked, at);
194
- }
195
-
196
- function readSourceFile(source) {
197
- const code = blankJsComments(source);
198
- return { source, code, masked: maskStrings(code), position: lineIndex(source) };
199
- }
200
-
201
- // Every key of the object literal between `start` and `end` (masked code), in order, and whether the literal also
202
- // spreads or computes keys, so that its keys are not all known.
203
- function objectKeys(masked, start, end) {
204
- let depth = 0;
205
- let top = "";
206
- for (let index = start; index < end; index += 1) {
207
- const char = masked[index];
208
- if ("([{".includes(char)) depth += 1;
209
- top += depth === 1 || char === "\n" ? char : " ";
210
- if (")]}".includes(char)) depth -= 1;
211
- }
212
- const keys = [...top.matchAll(/(?:^|[{,])\s*([A-Za-z_$][\w$]*)\s*(?::|(?=[,}]))/gu)].map((match) => ({
213
- key: match[1],
214
- at: start + match.index + match[0].length
215
- }));
216
- return { keys, partial: /(?:^|[{,])\s*(?:\.\.\.|["'`[])/u.test(top) };
217
- }
218
-
219
- // The object literal written at an offset: { start, end } in masked code, or null for anything else.
220
- function objectAt(masked, offset) {
221
- const start = nextCodeOffset(masked, offset);
222
- if (masked[start] !== "{") return null;
223
- const end = closingOffset(masked, start);
224
- return end < 0 ? null : { start, end };
225
- }
226
-
227
108
  // A table resource's schema fields that a CRUD contract could show: { names, partial }, or null when the schema is
228
109
  // not an object literal. The ownership fields, the stamped timestamps, the primary key and hidden fields are left out.
229
110
  function readSchemaFields(code, masked, keys, ownership) {
@@ -249,40 +130,142 @@ function readTableResources(file, { code, masked, position }) {
249
130
  for (const match of masked.matchAll(TABLE_RESOURCE_CALL)) {
250
131
  const object = objectArgument(masked, match.index, match[0].length);
251
132
  if (!object) continue;
252
- const keys = topLevelKeys(masked, object.start, object.end, ["name", "tableName", "ownership", "schema", "foreignKeys"]);
133
+ const keys = topLevelKeys(masked, object.start, object.end, ["name", "tableName", "ownership", "schema", "foreignKeys", "searchSchema"]);
253
134
  const ownership = keyValue(code, masked, keys, "ownership")?.text ?? null;
254
135
  resources.push({
255
136
  file,
256
137
  line: position(match.index).line,
257
138
  name: keyValue(code, masked, keys, "name")?.text ?? null,
258
139
  tableName: keyValue(code, masked, keys, "tableName")?.text ?? null,
259
- variable: /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*$/u.exec(masked.slice(Math.max(0, match.index - 120), match.index))?.[1] ?? null,
140
+ variable: assignedVariable(masked, match.index),
260
141
  fields: readSchemaFields(code, masked, keys, ownership),
261
- foreignKeys: keys.has("foreignKeys")
142
+ foreignKeys: keys.has("foreignKeys"),
143
+ searchQ: readSearchQ(masked, keys)
262
144
  });
263
145
  }
264
146
  return resources;
265
147
  }
266
148
 
267
- // Every CRUD contract a file defines: the variable of its table resource and the fields its `fields` names, null when
268
- // they are not all written as literal keys.
149
+ // Whether an object literal's `searchSchema` key defines q: true or false, or null when it is not written as an object
150
+ // literal whose keys are all known. `keys` are the literal's top-level keys (topLevelKeys).
151
+ function readSearchQ(masked, keys) {
152
+ const at = keys.get("searchSchema");
153
+ if (at === undefined) return false;
154
+ const schema = typeof at === "number" ? objectAt(masked, at) : null;
155
+ const named = schema ? objectKeys(masked, schema.start, schema.end) : null;
156
+ if (named?.keys.some(({ key }) => key === "q")) return true;
157
+ return named && !named.partial ? false : null;
158
+ }
159
+
160
+ // Every CRUD contract a file defines: its variable and namespace, the variable of its table resource and the fields its
161
+ // `fields` names, null when they are not all written as literal keys.
269
162
  function readCrudContracts({ code, masked }) {
270
163
  const contracts = [];
271
164
  for (const match of masked.matchAll(CRUD_CONTRACT_CALL)) {
165
+ const variable = assignedVariable(masked, match.index);
272
166
  const config = objectAt(masked, match.index + match[0].length);
273
167
  if (!config) {
274
- contracts.push({ resource: match[1], fields: null });
168
+ contracts.push({ variable, namespace: null, resource: match[1], fields: null });
275
169
  continue;
276
170
  }
277
- const at = topLevelKeys(masked, config.start, config.end, ["fields"]).get("fields");
171
+ const keys = topLevelKeys(masked, config.start, config.end, ["fields", "namespace"]);
172
+ const at = keys.get("fields");
278
173
  const fields = typeof at === "number" ? objectAt(masked, at) : null;
279
174
  const named = fields ? objectKeys(masked, fields.start, fields.end) : null;
280
175
  const known = at === undefined || (named && !named.partial);
281
- contracts.push({ resource: match[1], fields: at === undefined ? [] : known ? named.keys.map(({ key }) => key) : null });
176
+ contracts.push({
177
+ variable,
178
+ namespace: keyValue(code, masked, keys, "namespace")?.text ?? null,
179
+ resource: match[1],
180
+ fields: at === undefined ? [] : known ? named.keys.map(({ key }) => key) : null
181
+ });
282
182
  }
283
183
  return contracts;
284
184
  }
285
185
 
186
+ /**
187
+ * What a file says about searches: the namespaces its lookup fields pick from (`lookup: "<namespace>"`, or a relation
188
+ * `{ kind: "lookup", namespace }`), the table resource variables its owns entries give a searchSchema
189
+ * (`{ resource, searchSchema }`, with searchQ as readSearchQ reads it), and the CRUD contract variables its list screens
190
+ * show (`useCrudListScreen({ resource })`).
191
+ */
192
+ function readSearches(file, { code, masked, position }) {
193
+ const lookups = [];
194
+ for (const match of masked.matchAll(/\b(lookup|kind)\s*:/gu)) {
195
+ const value = readValue(code, masked, match.index + match[0].length).text;
196
+ if (match[1] === "lookup" && value) {
197
+ lookups.push({ file, line: position(match.index).line, namespace: value });
198
+ } else if (match[1] === "kind" && value === "lookup") {
199
+ const object = enclosingObject(masked, match.index);
200
+ const namespace = object && keyValue(code, masked, topLevelKeys(masked, object.start, object.end, ["namespace"]), "namespace")?.text;
201
+ if (namespace) lookups.push({ file, line: position(match.index).line, namespace });
202
+ }
203
+ }
204
+ const owned = [];
205
+ for (const match of masked.matchAll(/\bsearchSchema\s*:/gu)) {
206
+ const object = enclosingObject(masked, match.index);
207
+ const keys = object ? topLevelKeys(masked, object.start, object.end, ["resource", "searchSchema"]) : new Map();
208
+ const at = keys.get("resource");
209
+ const variable = typeof at === "number" ? identifierAt(code, at) : null;
210
+ if (variable) owned.push({ variable, searchQ: readSearchQ(masked, keys) });
211
+ }
212
+ const screens = [];
213
+ for (const match of masked.matchAll(/\buseCrudListScreen\s*\(/gu)) {
214
+ const object = objectArgument(masked, match.index, match[0].length);
215
+ const at = object && topLevelKeys(masked, object.start, object.end, ["resource"]).get("resource");
216
+ const contract = typeof at === "number" ? identifierAt(code, at) : null;
217
+ if (contract) screens.push({ file, line: position(match.index).line, contract });
218
+ }
219
+ return { lookups, owned, screens };
220
+ }
221
+
222
+ // A Vue component's <script> blocks, everything else blanked with its line breaks kept, so offsets and lines hold.
223
+ const vueScripts = (source) => {
224
+ let scripts = blank(source);
225
+ for (const match of source.matchAll(/(<script\b[^>]*>)([\s\S]*?)<\/script>/gu)) {
226
+ const start = match.index + match[1].length;
227
+ scripts = scripts.slice(0, start) + match[2] + scripts.slice(start + match[2].length);
228
+ }
229
+ return scripts;
230
+ };
231
+
232
+ // The resource name a namespace stands for, as @jskit-ai/resource-core's resourceNameFromNamespace makes it:
233
+ // `processing-lots` and `/processing-lots` are `processingLots`.
234
+ const resourceNameFromNamespace = (namespace) => namespace.replace(/^\/+|\/+$/gu, "").replace(/\//gu, "-")
235
+ .replace(/[-_\s]+([A-Za-z0-9])/gu, (_, letter) => letter.toUpperCase());
236
+
237
+ /**
238
+ * Fails each lookup and CRUD list screen whose app table resource defines no searchSchema.q, on the resource or an
239
+ * owns entry. A lookup names its table resource by namespace; a list screen by its CRUD contract's variable. One the
240
+ * check cannot follow (a framework resource, a contract or resource it cannot find, a searchSchema it cannot read) is
241
+ * left unjudged.
242
+ */
243
+ function checkSearchQ(resources, contracts, searches, report) {
244
+ const appResources = resources.filter((resource) => !resource.framework);
245
+ const ownedQ = Map.groupBy(searches.flatMap(({ owned }) => owned), ({ variable }) => variable);
246
+ // An unreadable searchSchema (null) counts as one that defines q.
247
+ const searchQ = (resource) => resource.searchQ !== false || (ownedQ.get(resource.variable) || []).some(({ searchQ: q }) => q !== false);
248
+ const unique = (list) => (list.length === 1 ? list[0] : null);
249
+ const where = (resource) => `${resource.name} (${resource.file}:${resource.line})`;
250
+ const fix = "give it searchSchema: { q: { type: \"string\", oneOf: [<the fields a person types>], filterOperator: \"like\" } }.";
251
+ for (const lookup of searches.flatMap(({ lookups }) => lookups)) {
252
+ const name = resourceNameFromNamespace(lookup.namespace);
253
+ const resource = unique(appResources.filter((entry) => entry.name === name));
254
+ if (resource && !searchQ(resource)) {
255
+ report(lookup, "data/search-q", `lookup "${lookup.namespace}" picks from ${where(resource)}, which defines no ` +
256
+ `searchSchema.q: the picker sends what the person types as q, so ${fix}`);
257
+ }
258
+ }
259
+ for (const screen of searches.flatMap(({ screens }) => screens)) {
260
+ const contract = unique(contracts.filter((entry) => entry.variable === screen.contract));
261
+ const resource = contract && unique(appResources.filter((entry) => entry.variable === contract.resource));
262
+ if (resource && !searchQ(resource)) {
263
+ report(screen, "data/search-q", `this list screen shows ${screen.contract}, whose table resource ${where(resource)} ` +
264
+ `defines no searchSchema.q: the list's Search box sends what the person types as q, so ${fix}`);
265
+ }
266
+ }
267
+ }
268
+
286
269
  // The fields of each app table resource that its CRUD contracts never name, for a resource with at least one contract
287
270
  // whose fields are all known.
288
271
  function unnamedFields(resources, contracts) {
@@ -568,6 +551,7 @@ async function checkDataAccess({ projectRoot = process.cwd() } = {}) {
568
551
  const scopes = [];
569
552
  const excuses = [];
570
553
  const capabilities = [];
554
+ const searches = [];
571
555
  const findings = [];
572
556
  const report = ({ file, line }, rule, message) => findings.push({ file, line, rule, message });
573
557
  for (const { file, absolute, framework } of sources) {
@@ -579,6 +563,7 @@ async function checkDataAccess({ projectRoot = process.cwd() } = {}) {
579
563
  scopes.push(...readSystemScopes(name, read).map((scope) => ({ ...scope, framework })));
580
564
  if (framework) continue;
581
565
  contracts.push(...readCrudContracts(read));
566
+ searches.push(readSearches(name, read));
582
567
  const fileExcuses = readInfrastructureExcuses(name, source);
583
568
  excuses.push(...fileExcuses);
584
569
  capabilities.push(...readRuntimeCapabilities(name, read).map((use) => ({ ...use, infrastructure: fileExcuses.length > 0 })));
@@ -589,7 +574,12 @@ async function checkDataAccess({ projectRoot = process.cwd() } = {}) {
589
574
  : disable.rules.length > 0 ? `eslint-disable turns off ${disable.rules.join(", ")}` : "eslint-disable names no rule");
590
575
  }
591
576
  }
577
+ for (const { file, absolute } of await vueFiles(root)) {
578
+ const source = await readFile(absolute, "utf8");
579
+ if (source.includes("useCrudListScreen")) searches.push(readSearches(file, readSourceFile(vueScripts(source))));
580
+ }
592
581
  checkTables(resources, report);
582
+ checkSearchQ(resources, contracts, searches, report);
593
583
  checkSystemScopes(scopes, report);
594
584
  const baseline = await readBaseline(root, report);
595
585
  checkBaseline(baseline, excuses, scopes, report);