@jskit-ai/jskit-catalog 0.2.44 → 0.2.46
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/catalog/packages.json +281 -281
- package/package.json +1 -1
- package/scripts/data-access-files.mjs +61 -16
- package/scripts/data-access-source.mjs +169 -0
- package/scripts/data-access.mjs +132 -142
- package/scripts/jskit.mjs +9 -3
- package/scripts/manifest-capabilities.mjs +89 -0
package/scripts/data-access.mjs
CHANGED
|
@@ -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 {
|
|
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.
|
|
19
|
-
//
|
|
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:
|
|
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
|
-
//
|
|
268
|
-
//
|
|
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
|
|
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({
|
|
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);
|
package/scripts/jskit.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
formatExportSummary
|
|
13
13
|
} from "./i18n.mjs";
|
|
14
14
|
import { checkDataAccess, formatDataAccessReport } from "./data-access.mjs";
|
|
15
|
+
import { checkManifestCapabilities, formatManifestCapabilitiesReport } from "./manifest-capabilities.mjs";
|
|
15
16
|
import { checkMaterial3, formatMaterial3Report } from "./material3.mjs";
|
|
16
17
|
import { checkProject, updateProject } from "./project-packages.mjs";
|
|
17
18
|
import { runTests } from "./test-command.mjs";
|
|
@@ -50,7 +51,10 @@ function usage() {
|
|
|
50
51
|
" installed JSKIT packages share a table or a name (data/one-resource-per-table), or when a system",
|
|
51
52
|
" scope (a feature's systemScope, a systemContext() call) gives no literal reason (data/system-scope),",
|
|
52
53
|
" and it lists every system scope and every file the infrastructure exception lifts past the",
|
|
53
|
-
" data-access lint rules for review.",
|
|
54
|
+
" data-access lint rules for review. It fails when an application package's manifest",
|
|
55
|
+
" (jskit.capabilities) misses a capability its features (server providers) provide or require, or",
|
|
56
|
+
" provides one none of them does (manifest/capabilities), and lists each requirement no feature has,",
|
|
57
|
+
" which only install time may need, for review.",
|
|
54
58
|
" check m3 Check application UI source against the JSKIT Material 3 guide alone, with each finding's guide",
|
|
55
59
|
" section and every exception: the whole app, or only the given files and directories (a section,",
|
|
56
60
|
" or a task's changed files). Plain jskit check applies the same rules to the whole app.",
|
|
@@ -272,6 +276,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
272
276
|
const ui = await checkMaterial3({ projectRoot: options.projectRoot });
|
|
273
277
|
const tests = await checkTestRuns({ projectRoot: options.projectRoot, manifests: result.manifests });
|
|
274
278
|
const data = await checkDataAccess({ projectRoot: options.projectRoot });
|
|
279
|
+
const capabilities = await checkManifestCapabilities({ projectRoot: options.projectRoot, manifests: result.manifests });
|
|
275
280
|
// The data rules first: a graph that fails on many pin versions would otherwise bury them.
|
|
276
281
|
const reports = [
|
|
277
282
|
formatDataAccessReport(data),
|
|
@@ -280,9 +285,10 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
280
285
|
`${result.installations.length} resolved package installation(s).`
|
|
281
286
|
: `JSKIT project graph is inconsistent:\n- ${result.issues.join("\n- ")}`,
|
|
282
287
|
formatMaterial3Report(ui),
|
|
283
|
-
formatTestRunsReport(tests)
|
|
288
|
+
formatTestRunsReport(tests),
|
|
289
|
+
formatManifestCapabilitiesReport(capabilities)
|
|
284
290
|
];
|
|
285
|
-
if (!result.ok || !ui.ok || !tests.ok || !data.ok) {
|
|
291
|
+
if (!result.ok || !ui.ok || !tests.ok || !data.ok || !capabilities.ok) {
|
|
286
292
|
throw new Error(reports.join("\n"));
|
|
287
293
|
}
|
|
288
294
|
process.stdout.write(`${reports.join("\n")}\n`);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { toPosixPath } from "./files.mjs";
|
|
5
|
+
|
|
6
|
+
// The manifest part of plain `jskit check`: an application package's manifest (`jskit.capabilities` in its
|
|
7
|
+
// package.json) names what its features (the server providers `jskit.runtime.server.providers` declares) provide and
|
|
8
|
+
// require. The server refuses to start on a drifted one; here it fails the check instead, with the capability to add or
|
|
9
|
+
// remove. The kernel the package itself resolves loads the features and compares them, by the rules it applies at
|
|
10
|
+
// start-up, so the two never disagree.
|
|
11
|
+
|
|
12
|
+
const GUIDE = "https://mobily-enterprises.github.io/jskit-ai/guide/framework/typed-features#package-manifest";
|
|
13
|
+
const KERNEL_PLATFORM = "@jskit-ai/kernel/server/platform";
|
|
14
|
+
|
|
15
|
+
// The line naming a capability in a manifest's `capabilities` block, else the block's own line.
|
|
16
|
+
function capabilityLine(contents, capabilityId = "") {
|
|
17
|
+
const text = String(contents);
|
|
18
|
+
const block = text.indexOf('"capabilities"');
|
|
19
|
+
if (block < 0) return 1;
|
|
20
|
+
const named = capabilityId ? text.indexOf(`"${capabilityId}"`, block) : -1;
|
|
21
|
+
return text.slice(0, named < 0 ? block : named).split("\n").length;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The kernel's platform module as the package resolves it, else as the app does; null when neither has one.
|
|
25
|
+
async function kernelPlatform(manifestPath, root) {
|
|
26
|
+
for (const base of [manifestPath, path.join(root, "package.json")]) {
|
|
27
|
+
let resolved;
|
|
28
|
+
try {
|
|
29
|
+
resolved = createRequire(base).resolve(KERNEL_PLATFORM);
|
|
30
|
+
} catch {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
return await import(pathToFileURL(resolved).href);
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function checkManifestCapabilities({ projectRoot, manifests }) {
|
|
39
|
+
const root = path.resolve(projectRoot);
|
|
40
|
+
const findings = [];
|
|
41
|
+
const installTime = [];
|
|
42
|
+
let packages = 0;
|
|
43
|
+
for (const manifest of manifests) {
|
|
44
|
+
const jskit = manifest.value?.jskit;
|
|
45
|
+
if (!Array.isArray(jskit?.runtime?.server?.providers) || jskit.runtime.server.providers.length < 1) continue;
|
|
46
|
+
const file = toPosixPath(path.relative(root, manifest.absolutePath)) || "package.json";
|
|
47
|
+
const at = (capabilityId, message) => findings.push({ file, line: capabilityLine(manifest.contents, capabilityId), rule: "manifest/capabilities", message });
|
|
48
|
+
const kernel = await kernelPlatform(manifest.absolutePath, root);
|
|
49
|
+
if (typeof kernel?.packageCapabilityDrift !== "function") {
|
|
50
|
+
at("", `declares server providers, but no installed ${KERNEL_PLATFORM} compares a manifest with them; run jskit update.`);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
let drift;
|
|
54
|
+
try {
|
|
55
|
+
drift = await kernel.packageCapabilityDrift({
|
|
56
|
+
packageId: String(manifest.value.name || file),
|
|
57
|
+
packageRoot: path.dirname(manifest.absolutePath),
|
|
58
|
+
packageMetadata: jskit
|
|
59
|
+
});
|
|
60
|
+
} catch (error) {
|
|
61
|
+
at("", `declares server providers that do not load: ${error instanceof Error ? error.message : String(error)}`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!drift) continue;
|
|
65
|
+
packages += 1;
|
|
66
|
+
for (const id of drift.unlistedProvides) at("", `its features provide ${id}, which jskit.capabilities.provides does not list; add it.`);
|
|
67
|
+
for (const id of drift.extraProvides) at(id, `jskit.capabilities.provides lists ${id}, which none of its features provides; remove it.`);
|
|
68
|
+
for (const id of drift.unlistedRequires) at("", `its features require ${id}, which jskit.capabilities.requires does not list; add it.`);
|
|
69
|
+
for (const id of drift.extraRequires) installTime.push({ file, line: capabilityLine(manifest.contents, id), capabilityId: id });
|
|
70
|
+
}
|
|
71
|
+
findings.sort((left, right) => left.file.localeCompare(right.file) || left.line - right.line);
|
|
72
|
+
return { ok: findings.length < 1, findings, installTime, packages };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function formatManifestCapabilitiesReport(result) {
|
|
76
|
+
const lines = result.findings.flatMap(({ file, line, rule, message }) => [`${file}:${line} ${rule} ${message}`, ` ${GUIDE}`]);
|
|
77
|
+
if (result.installTime.length > 0) {
|
|
78
|
+
lines.push(`Manifest requirements no feature has (${result.installTime.length}), each needed only at install time, such as ` +
|
|
79
|
+
`the database a migration needs; remove any other (${GUIDE}):`);
|
|
80
|
+
for (const { file, line, capabilityId } of result.installTime) lines.push(` ${file}:${line} ${capabilityId}`);
|
|
81
|
+
}
|
|
82
|
+
lines.push(result.ok
|
|
83
|
+
? `JSKIT package manifests name what their features provide and require in ${result.packages} package(s).`
|
|
84
|
+
: `JSKIT package manifests: ${result.findings.length} error(s). A package's jskit.capabilities lists exactly what its ` +
|
|
85
|
+
"features provide, and every capability they require beyond the kernel's own and the package's.");
|
|
86
|
+
return lines.join("\n");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export { checkManifestCapabilities, formatManifestCapabilitiesReport };
|