@jskit-ai/jskit-catalog 0.2.34 → 0.2.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/catalog/packages.json +477 -384
- package/package.json +1 -1
- package/scripts/browser-test-servers.mjs +45 -9
- package/scripts/browser-tests.mjs +16 -3
- package/scripts/data-access.mjs +564 -0
- package/scripts/jskit.mjs +13 -2
- package/scripts/project-packages.mjs +65 -3
- package/scripts/verify-packages.mjs +23 -0
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
import { readdir, readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { toPosixPath } from "./files.mjs";
|
|
4
|
+
import { blankJsComments, lineIndex } from "./material3-source.mjs";
|
|
5
|
+
|
|
6
|
+
// The data part of plain `jskit check`, read from source, never run. It backs up what the lint rules cannot see across
|
|
7
|
+
// files: every table has exactly one table resource (`defineTableResource({ name, tableName })`) across the app and its
|
|
8
|
+
// installed @jskit-ai packages, so a duplicate name or table fails here before the boot-time throw; every system scope
|
|
9
|
+
// (a feature's `systemScope: "<reason>"` and every `systemContext("<reason>")` call) is listed with its reason, and one
|
|
10
|
+
// without a literal reason fails; and every file the infrastructure exception lifts past the data-access lint rules is
|
|
11
|
+
// listed for review. In the app's own code, which no lint directive can hide from it, a feature's system scope and an
|
|
12
|
+
// infrastructure exception fail until the checked-in BASELINE_FILE lists them, so each new one is reviewed; a module
|
|
13
|
+
// that requires runtime.database or runtime.resources fails unless it is declared infrastructure; and an eslint-disable
|
|
14
|
+
// that names a data-access rule, or no rule at all, fails in any file but the client's. As information, it lists the
|
|
15
|
+
// fields of each app table resource that its CRUD contracts never name, which no API shows or accepts.
|
|
16
|
+
|
|
17
|
+
const GUIDE = "https://mobily-enterprises.github.io/jskit-ai/guide/framework/data-access";
|
|
18
|
+
const SECTIONS = Object.freeze({
|
|
19
|
+
"data/one-resource-per-table": "layer-1-the-table-resource",
|
|
20
|
+
"data/system-scope": "background-work",
|
|
21
|
+
"data/infrastructure-excuses": "hard-rules",
|
|
22
|
+
"data/baseline": "hard-rules",
|
|
23
|
+
"data/runtime-capability": "the-resource-is-the-only-door",
|
|
24
|
+
"data/lint-disabled": "hard-rules",
|
|
25
|
+
"data/unnamed-fields": "layer-2-the-crud-surface"
|
|
26
|
+
});
|
|
27
|
+
// What each rule asks, said once above its findings in the report.
|
|
28
|
+
const RULE_EXPLANATIONS = Object.freeze({
|
|
29
|
+
"data/one-resource-per-table": "every table has exactly one table resource, under a name no other resource has.",
|
|
30
|
+
"data/system-scope": "every system scope and systemContext() call gives its reason as a string literal.",
|
|
31
|
+
"data/baseline": "each infrastructure exception and feature system scope of the app is reviewed, then listed in the checked-in " +
|
|
32
|
+
"jskit-data-access.json; review each one below, then add the entry it shows.",
|
|
33
|
+
"data/runtime-capability": "a module that requires runtime.database or runtime.resources reaches tables past the features that " +
|
|
34
|
+
"own them. A feature owns its tables with owns and gets their handles; only a module whose job is data access itself, " +
|
|
35
|
+
"declared with the infrastructure exception, takes them.",
|
|
36
|
+
"data/lint-disabled": "eslint-disable cannot turn off a data-access rule, and one that names no rule turns them all off. Change the " +
|
|
37
|
+
"code, or name the rules the directive turns off."
|
|
38
|
+
});
|
|
39
|
+
// The reviewed list of the app's infrastructure exceptions and feature system scopes, at the app's root.
|
|
40
|
+
const BASELINE_FILE = "jskit-data-access.json";
|
|
41
|
+
const DATA_ACCESS_RULES = Object.freeze(["no-direct-knex", "no-transaction-reader", "sql-literal", "no-hand-scope", "no-hand-context", "knex-reason"]);
|
|
42
|
+
// An eslint-disable directive and the rules it names, before any "--" description.
|
|
43
|
+
const LINT_DISABLE = /\/[/*]\s*eslint-disable(?:-next-line|-line)?(?![\w-])([^\n]*)/gu;
|
|
44
|
+
// A module that takes the database, or every table, past the features that own them.
|
|
45
|
+
const RUNTIME_CAPABILITY = /(["'`])(runtime\.(?:database|resources))\1/gu;
|
|
46
|
+
// An app's client code, which the data-access rules leave alone: its own src/ but src/server and src/shared, and every
|
|
47
|
+
// package's src/client.
|
|
48
|
+
const CLIENT_FILE = /^src\/(?!server\/|shared\/)|(?:^|\/)src\/client\//u;
|
|
49
|
+
const FRAMEWORK_DIRECTORY = path.join("node_modules", "@jskit-ai");
|
|
50
|
+
const SOURCE_FILE = /\.[cm]?js$/u;
|
|
51
|
+
const SKIPPED_FILE = /\.(?:test|spec|vitest)\.[cm]?js$/u;
|
|
52
|
+
// Code that defines no table resource or system scope an app runs: dependencies, build output, tests and fixtures,
|
|
53
|
+
// migrations and seeds, and the examples, templates and documentation JSKIT packages carry.
|
|
54
|
+
const SKIPPED_DIRECTORIES = new Set([
|
|
55
|
+
"node_modules", "dist", "build", "coverage", "test", "tests", "__tests__", "e2e", "fixtures", "test-results",
|
|
56
|
+
"migrations", "seeds", "templates", "patterns", "examples", "docs", "site", "skills", "test-support"
|
|
57
|
+
]);
|
|
58
|
+
const MARKERS = /defineTableResource|defineCrudContract|systemScope|systemContext|readable-check: allow|eslint-disable|runtime\.(?:database|resources)/u;
|
|
59
|
+
const TABLE_RESOURCE_CALL = /\bdefineTableResource\s*\(/gu;
|
|
60
|
+
// A CRUD contract on a table resource named by its variable: `defineCrudContract(receivalResource, { ... })`.
|
|
61
|
+
const CRUD_CONTRACT_CALL = /\bdefineCrudContract\s*\(\s*([A-Za-z_$][\w$]*)\s*,\s*/gu;
|
|
62
|
+
// The fields an ownership kind keeps for itself: json-rest-api fills them from the context.
|
|
63
|
+
const OWNERSHIP_FIELDS = Object.freeze({
|
|
64
|
+
workspace: ["workspaceId"],
|
|
65
|
+
workspace_or_shared: ["workspaceId"],
|
|
66
|
+
user: ["userId"],
|
|
67
|
+
workspace_user: ["workspaceId", "userId"]
|
|
68
|
+
});
|
|
69
|
+
// The stamped timestamps every contract shows without an entry.
|
|
70
|
+
const STAMPED_FIELDS = Object.freeze(["createdAt", "updatedAt"]);
|
|
71
|
+
const FEATURE_CALL = /\bdefine(?:Crud)?Feature\s*\(/gu;
|
|
72
|
+
const SYSTEM_CONTEXT_CALL = /\bsystemContext\s*\(/gu;
|
|
73
|
+
const INFRASTRUCTURE_EXCUSE = /readable-check: allow (?:jskit\/)?(no-direct-knex|no-transaction-reader|no-hand-scope|sql-literal) - (infrastructure:.*?)\s*(?:\*\/)?\s*$/u;
|
|
74
|
+
|
|
75
|
+
const blank = (text) => text.replace(/[^\n]/gu, " ");
|
|
76
|
+
|
|
77
|
+
// Code with its comments and the contents of its strings and templates blanked to spaces, every offset kept: calls
|
|
78
|
+
// and keys are found in it, values are read from the code at the same offsets.
|
|
79
|
+
function maskStrings(code) {
|
|
80
|
+
let output = "";
|
|
81
|
+
let index = 0;
|
|
82
|
+
while (index < code.length) {
|
|
83
|
+
const quote = code[index];
|
|
84
|
+
if (quote !== "\"" && quote !== "'" && quote !== "`") {
|
|
85
|
+
output += quote;
|
|
86
|
+
index += 1;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
let end = index + 1;
|
|
90
|
+
while (end < code.length && code[end] !== quote && (quote === "`" || code[end] !== "\n")) {
|
|
91
|
+
end += code[end] === "\\" ? 2 : 1;
|
|
92
|
+
}
|
|
93
|
+
output += quote + blank(code.slice(index + 1, end)) + code.slice(end, end + 1);
|
|
94
|
+
index = end + 1;
|
|
95
|
+
}
|
|
96
|
+
return output;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The offset just past the bracket that closes the one at `start`, in masked code; -1 when it never closes.
|
|
100
|
+
function closingOffset(masked, start) {
|
|
101
|
+
let depth = 0;
|
|
102
|
+
for (let index = start; index < masked.length; index += 1) {
|
|
103
|
+
if ("([{".includes(masked[index])) depth += 1;
|
|
104
|
+
else if (")]}".includes(masked[index]) && --depth === 0) return index + 1;
|
|
105
|
+
}
|
|
106
|
+
return -1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const nextCodeOffset = (text, offset) => offset + (/^\s*/u.exec(text.slice(offset))?.[0].length || 0);
|
|
110
|
+
|
|
111
|
+
// The string a top-level `const NAME = "..."` gives, or null.
|
|
112
|
+
function constantValue(code, name) {
|
|
113
|
+
const match = new RegExp(`(?:^|[\\s;])const\\s+${name}\\s*=\\s*(["'\`])((?:\\\\.|(?!\\1)[^\\\\\\n])*)\\1\\s*[;,\\n]`, "u").exec(code);
|
|
114
|
+
return match && !(match[1] === "`" && match[2].includes("${")) ? match[2] : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The value written at an offset, read up to the comma or bracket that ends it: { text } for a string literal, a
|
|
119
|
+
* template without expressions, or a const holding one; { text: null } for anything else.
|
|
120
|
+
*/
|
|
121
|
+
function readValue(code, masked, offset) {
|
|
122
|
+
const start = nextCodeOffset(code, offset);
|
|
123
|
+
const quote = code[start];
|
|
124
|
+
if (quote === "\"" || quote === "'" || quote === "`") {
|
|
125
|
+
let end = start + 1;
|
|
126
|
+
while (end < code.length && code[end] !== quote) end += code[end] === "\\" ? 2 : 1;
|
|
127
|
+
const after = masked[nextCodeOffset(masked, end + 1)];
|
|
128
|
+
const text = code.slice(start + 1, end);
|
|
129
|
+
const whole = after === "," || after === "}" || after === ")";
|
|
130
|
+
return { text: whole && !(quote === "`" && text.includes("${")) ? text.replace(/\\(.)/gu, "$1") : null };
|
|
131
|
+
}
|
|
132
|
+
const identifier = /^[A-Za-z_$][\w$]*(?=\s*[,})])/u.exec(code.slice(start));
|
|
133
|
+
return { text: identifier ? constantValue(code, identifier[0]) : null };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The top-level keys of the object literal between `start` and `end`, in masked code: for each wanted key, the offset
|
|
137
|
+
// just past its colon, or "shorthand" for `{ name }`.
|
|
138
|
+
function topLevelKeys(masked, start, end, names) {
|
|
139
|
+
let depth = 0;
|
|
140
|
+
let top = "";
|
|
141
|
+
for (let index = start; index < end; index += 1) {
|
|
142
|
+
const char = masked[index];
|
|
143
|
+
if ("([{".includes(char)) depth += 1;
|
|
144
|
+
top += depth === 1 || char === "\n" ? char : " ";
|
|
145
|
+
if (")]}".includes(char)) depth -= 1;
|
|
146
|
+
}
|
|
147
|
+
const keys = new Map();
|
|
148
|
+
for (const match of top.matchAll(/(?:^|[{,])\s*([A-Za-z_$][\w$]*)\s*(:|(?=[,}]))/gu)) {
|
|
149
|
+
const [whole, key, colon] = match;
|
|
150
|
+
if (names.includes(key) && !keys.has(key)) keys.set(key, colon ? start + match.index + whole.length : "shorthand");
|
|
151
|
+
}
|
|
152
|
+
return keys;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// The object literal a call at `callOffset` takes as its first argument: { start, end } in masked code, or null when the
|
|
156
|
+
// call is a declaration (`function defineFeature(`) or its first argument is anything else.
|
|
157
|
+
function objectArgument(masked, callOffset, matchLength) {
|
|
158
|
+
if (/function\s*\*?\s*$/u.test(masked.slice(Math.max(0, callOffset - 20), callOffset))) return null;
|
|
159
|
+
const start = nextCodeOffset(masked, callOffset + matchLength);
|
|
160
|
+
if (masked[start] !== "{") return null;
|
|
161
|
+
const end = closingOffset(masked, start);
|
|
162
|
+
return end < 0 ? null : { start, end };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// The value of one key of an object literal, as readValue reads it; undefined when the object has no such key.
|
|
166
|
+
function keyValue(code, masked, keys, key) {
|
|
167
|
+
const at = keys.get(key);
|
|
168
|
+
if (at === undefined) return undefined;
|
|
169
|
+
return at === "shorthand" ? { text: constantValue(code, key) } : readValue(code, masked, at);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function readSourceFile(source) {
|
|
173
|
+
const code = blankJsComments(source);
|
|
174
|
+
return { source, code, masked: maskStrings(code), position: lineIndex(source) };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Every key of the object literal between `start` and `end` (masked code), in order, and whether the literal also
|
|
178
|
+
// spreads or computes keys, so that its keys are not all known.
|
|
179
|
+
function objectKeys(masked, start, end) {
|
|
180
|
+
let depth = 0;
|
|
181
|
+
let top = "";
|
|
182
|
+
for (let index = start; index < end; index += 1) {
|
|
183
|
+
const char = masked[index];
|
|
184
|
+
if ("([{".includes(char)) depth += 1;
|
|
185
|
+
top += depth === 1 || char === "\n" ? char : " ";
|
|
186
|
+
if (")]}".includes(char)) depth -= 1;
|
|
187
|
+
}
|
|
188
|
+
const keys = [...top.matchAll(/(?:^|[{,])\s*([A-Za-z_$][\w$]*)\s*(?::|(?=[,}]))/gu)].map((match) => ({
|
|
189
|
+
key: match[1],
|
|
190
|
+
at: start + match.index + match[0].length
|
|
191
|
+
}));
|
|
192
|
+
return { keys, partial: /(?:^|[{,])\s*(?:\.\.\.|["'`[])/u.test(top) };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// The object literal written at an offset: { start, end } in masked code, or null for anything else.
|
|
196
|
+
function objectAt(masked, offset) {
|
|
197
|
+
const start = nextCodeOffset(masked, offset);
|
|
198
|
+
if (masked[start] !== "{") return null;
|
|
199
|
+
const end = closingOffset(masked, start);
|
|
200
|
+
return end < 0 ? null : { start, end };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// A table resource's schema fields that a CRUD contract could show: { names, partial }, or null when the schema is
|
|
204
|
+
// not an object literal. The ownership fields, the stamped timestamps, the primary key and hidden fields are left out.
|
|
205
|
+
function readSchemaFields(code, masked, keys, ownership) {
|
|
206
|
+
const at = keys.get("schema");
|
|
207
|
+
const schema = typeof at === "number" ? objectAt(masked, at) : null;
|
|
208
|
+
if (!schema) return null;
|
|
209
|
+
const { keys: fields, partial } = objectKeys(masked, schema.start, schema.end);
|
|
210
|
+
const kept = new Set([...(OWNERSHIP_FIELDS[ownership] || []), ...STAMPED_FIELDS]);
|
|
211
|
+
const names = fields.filter(({ key, at: valueAt }) => {
|
|
212
|
+
if (kept.has(key)) return false;
|
|
213
|
+
const field = objectAt(masked, valueAt);
|
|
214
|
+
if (!field) return true;
|
|
215
|
+
const flags = topLevelKeys(masked, field.start, field.end, ["hidden", "primary"]);
|
|
216
|
+
return ![...flags.values()].some((flagAt) => typeof flagAt === "number" && code.slice(nextCodeOffset(code, flagAt)).startsWith("true"));
|
|
217
|
+
}).map(({ key }) => key);
|
|
218
|
+
return { names, partial };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Every table resource a file defines: its name and table, each null when the source does not write it as a literal,
|
|
222
|
+
// the variable it is assigned to and the schema fields a CRUD contract could show.
|
|
223
|
+
function readTableResources(file, { code, masked, position }) {
|
|
224
|
+
const resources = [];
|
|
225
|
+
for (const match of masked.matchAll(TABLE_RESOURCE_CALL)) {
|
|
226
|
+
const object = objectArgument(masked, match.index, match[0].length);
|
|
227
|
+
if (!object) continue;
|
|
228
|
+
const keys = topLevelKeys(masked, object.start, object.end, ["name", "tableName", "ownership", "schema"]);
|
|
229
|
+
const ownership = keyValue(code, masked, keys, "ownership")?.text ?? null;
|
|
230
|
+
resources.push({
|
|
231
|
+
file,
|
|
232
|
+
line: position(match.index).line,
|
|
233
|
+
name: keyValue(code, masked, keys, "name")?.text ?? null,
|
|
234
|
+
tableName: keyValue(code, masked, keys, "tableName")?.text ?? null,
|
|
235
|
+
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,
|
|
236
|
+
fields: readSchemaFields(code, masked, keys, ownership)
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
return resources;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Every CRUD contract a file defines: the variable of its table resource and the fields its `fields` names, null when
|
|
243
|
+
// they are not all written as literal keys.
|
|
244
|
+
function readCrudContracts({ code, masked }) {
|
|
245
|
+
const contracts = [];
|
|
246
|
+
for (const match of masked.matchAll(CRUD_CONTRACT_CALL)) {
|
|
247
|
+
const config = objectAt(masked, match.index + match[0].length);
|
|
248
|
+
if (!config) {
|
|
249
|
+
contracts.push({ resource: match[1], fields: null });
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const at = topLevelKeys(masked, config.start, config.end, ["fields"]).get("fields");
|
|
253
|
+
const fields = typeof at === "number" ? objectAt(masked, at) : null;
|
|
254
|
+
const named = fields ? objectKeys(masked, fields.start, fields.end) : null;
|
|
255
|
+
const known = at === undefined || (named && !named.partial);
|
|
256
|
+
contracts.push({ resource: match[1], fields: at === undefined ? [] : known ? named.keys.map(({ key }) => key) : null });
|
|
257
|
+
}
|
|
258
|
+
return contracts;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// The fields of each app table resource that its CRUD contracts never name, for a resource with at least one contract
|
|
262
|
+
// whose fields are all known.
|
|
263
|
+
function unnamedFields(resources, contracts) {
|
|
264
|
+
const byResource = Map.groupBy(contracts, ({ resource }) => resource);
|
|
265
|
+
const unnamed = [];
|
|
266
|
+
for (const resource of resources) {
|
|
267
|
+
const own = byResource.get(resource.variable);
|
|
268
|
+
if (resource.framework || !resource.fields || !own || own.some(({ fields }) => fields === null)) continue;
|
|
269
|
+
const named = new Set(own.flatMap(({ fields }) => fields));
|
|
270
|
+
const fields = resource.fields.names.filter((name) => !named.has(name));
|
|
271
|
+
if (fields.length > 0) unnamed.push({ file: resource.file, line: resource.line, name: resource.name, fields });
|
|
272
|
+
}
|
|
273
|
+
return unnamed;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// A function of the file's own named systemContext: declared in the file (`function systemContext(`, `const
|
|
277
|
+
// systemContext =`), or handed in under a JSDoc type that is not JSKIT's SystemContext factory, such as
|
|
278
|
+
// `@param {() => { root: string }} dependencies.systemContext`.
|
|
279
|
+
const LOCAL_SYSTEM_CONTEXT = /\bfunction\s*\*?\s*systemContext\s*\(|\b(?:const|let|var)\s+systemContext\s*=/u;
|
|
280
|
+
const PARAM_TYPE = /@param\s*\{/gu;
|
|
281
|
+
const SYSTEM_CONTEXT_PARAM = /^\s*\[?(?:[\w$]+\.)*systemContext\b/u;
|
|
282
|
+
|
|
283
|
+
// Whether the file's systemContext is its own function, not the one JSKIT hands a feature with a systemScope: the
|
|
284
|
+
// check reads JSKIT's binding, not every function of that name.
|
|
285
|
+
function ownsSystemContextName(source, masked) {
|
|
286
|
+
if (LOCAL_SYSTEM_CONTEXT.test(masked)) return true;
|
|
287
|
+
for (const match of source.matchAll(PARAM_TYPE)) {
|
|
288
|
+
const close = closingOffset(source, match.index + match[0].length - 1);
|
|
289
|
+
if (close > 0 && SYSTEM_CONTEXT_PARAM.test(source.slice(close, close + 120))) {
|
|
290
|
+
return !/\bSystemContext\b/u.test(source.slice(match.index, close));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Every system scope a file declares or calls: a feature's systemScope and each call of JSKIT's systemContext(...),
|
|
297
|
+
// with its reason, null when it is not a literal.
|
|
298
|
+
function readSystemScopes(file, { source, code, masked, position }) {
|
|
299
|
+
const scopes = [];
|
|
300
|
+
for (const match of masked.matchAll(FEATURE_CALL)) {
|
|
301
|
+
const object = objectArgument(masked, match.index, match[0].length);
|
|
302
|
+
if (!object) continue;
|
|
303
|
+
const keys = topLevelKeys(masked, object.start, object.end, ["id", "systemScope"]);
|
|
304
|
+
const reason = keyValue(code, masked, keys, "systemScope");
|
|
305
|
+
if (reason === undefined) continue;
|
|
306
|
+
scopes.push({ file, line: position(match.index).line, kind: "feature", id: keyValue(code, masked, keys, "id")?.text || "", reason: reason.text });
|
|
307
|
+
}
|
|
308
|
+
const calls = ownsSystemContextName(source, masked) ? [] : masked.matchAll(SYSTEM_CONTEXT_CALL);
|
|
309
|
+
for (const match of calls) {
|
|
310
|
+
const open = match.index + match[0].length - 1;
|
|
311
|
+
const close = closingOffset(masked, open);
|
|
312
|
+
const before = masked.slice(Math.max(0, match.index - 20), match.index);
|
|
313
|
+
// A declaration, not a call: `function systemContext(reason) {` or a method `systemContext(reason) {`.
|
|
314
|
+
if (close < 0 || /function\s*\*?\s*$/u.test(before) || masked[nextCodeOffset(masked, close)] === "{") continue;
|
|
315
|
+
const empty = masked.slice(open + 1, close - 1).trim() === "";
|
|
316
|
+
const reason = empty ? { text: "" } : readValue(code, masked, open + 1);
|
|
317
|
+
scopes.push({ file, line: position(match.index).line, kind: "call", id: "", reason: reason.text });
|
|
318
|
+
}
|
|
319
|
+
return scopes;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// The infrastructure exceptions in a file's first lines, before its first line of code.
|
|
323
|
+
function readInfrastructureExcuses(file, source) {
|
|
324
|
+
const header = /^(?:#![^\n]*\n)?(?:\s|\/\/[^\n]*|\/\*[\s\S]*?\*\/)*/u.exec(source)[0];
|
|
325
|
+
return header.split("\n").flatMap((text, index) => {
|
|
326
|
+
const match = INFRASTRUCTURE_EXCUSE.exec(text);
|
|
327
|
+
return match ? [{ file, line: index + 1, rule: match[1], reason: match[2] }] : [];
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Every eslint-disable in a file that turns off a data-access rule: one that names one, or one that names no rule and so
|
|
332
|
+
// turns off every rule.
|
|
333
|
+
function readLintDisables(file, source, position) {
|
|
334
|
+
const disables = [];
|
|
335
|
+
for (const match of source.matchAll(LINT_DISABLE)) {
|
|
336
|
+
const named = match[1].replace(/\*\/.*$/u, "").split(/\s--\s|\s--$/u)[0].split(",").map((rule) => rule.trim()).filter(Boolean);
|
|
337
|
+
const rules = named.filter((rule) => DATA_ACCESS_RULES.includes(rule.replace(/^jskit\//u, "")));
|
|
338
|
+
if (named.length < 1 || rules.length > 0) {
|
|
339
|
+
disables.push({ file, line: position(match.index).line, rules });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return disables;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Every place a file's code takes runtime.database or runtime.resources: each time it names one outside a provides
|
|
346
|
+
// object, the module that provides it.
|
|
347
|
+
function readRuntimeCapabilities(file, { code, masked, position }) {
|
|
348
|
+
const provides = [...masked.matchAll(/\bprovides\s*:\s*\{/gu)].map((match) => {
|
|
349
|
+
const start = match.index + match[0].length - 1;
|
|
350
|
+
return { start, end: closingOffset(masked, start) };
|
|
351
|
+
});
|
|
352
|
+
return [...code.matchAll(RUNTIME_CAPABILITY)]
|
|
353
|
+
.filter((match) => !provides.some(({ start, end }) => match.index > start && match.index < end))
|
|
354
|
+
.map((match) => ({ file, line: position(match.index).line, capability: match[2] }));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// The app's reviewed baseline, or empty lists when it has none; a file that is not that JSON is a finding.
|
|
358
|
+
async function readBaseline(root, report) {
|
|
359
|
+
let text;
|
|
360
|
+
try {
|
|
361
|
+
text = await readFile(path.join(root, BASELINE_FILE), "utf8");
|
|
362
|
+
} catch (error) {
|
|
363
|
+
if (error?.code === "ENOENT") return { infrastructure: [], systemScopes: [] };
|
|
364
|
+
throw error;
|
|
365
|
+
}
|
|
366
|
+
try {
|
|
367
|
+
const baseline = JSON.parse(text);
|
|
368
|
+
return {
|
|
369
|
+
infrastructure: Array.isArray(baseline.infrastructure) ? baseline.infrastructure : [],
|
|
370
|
+
systemScopes: Array.isArray(baseline.systemScopes) ? baseline.systemScopes : []
|
|
371
|
+
};
|
|
372
|
+
} catch {
|
|
373
|
+
report({ file: BASELINE_FILE, line: 1 }, "data/baseline", `${BASELINE_FILE} is not JSON; it lists { "infrastructure": [{ "file", "rule" }], "systemScopes": [{ "feature", "systemScope" }] }.`);
|
|
374
|
+
return { infrastructure: [], systemScopes: [] };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Fails each infrastructure exception and feature system scope of the app's own code that the baseline does not list.
|
|
379
|
+
function checkBaseline(baseline, excuses, scopes, report) {
|
|
380
|
+
const listed = (list, entry) => list.some((item) => Object.entries(entry).every(([key, value]) => item?.[key] === value));
|
|
381
|
+
for (const { file, line, rule } of excuses) {
|
|
382
|
+
if (!listed(baseline.infrastructure, { file, rule })) {
|
|
383
|
+
report({ file, line }, "data/baseline", `the infrastructure exception from ${rule}: once the whole module's job is data access, add ${JSON.stringify({ file, rule })} to "infrastructure".`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
for (const { file, line, kind, id, reason, framework } of scopes) {
|
|
387
|
+
if (framework || kind !== "feature" || reason === null || !reason.trim()) continue;
|
|
388
|
+
if (!listed(baseline.systemScopes, { feature: id, systemScope: reason })) {
|
|
389
|
+
report({ file, line }, "data/baseline", `the system scope of feature "${id}": once it must work across every workspace and user, add ${JSON.stringify({ feature: id, systemScope: reason })} to "systemScopes".`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function walk(root, directory, files) {
|
|
395
|
+
let entries;
|
|
396
|
+
try {
|
|
397
|
+
entries = await readdir(path.join(root, directory), { withFileTypes: true });
|
|
398
|
+
} catch (error) {
|
|
399
|
+
if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return;
|
|
400
|
+
throw error;
|
|
401
|
+
}
|
|
402
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
403
|
+
const relative = directory ? path.join(directory, entry.name) : entry.name;
|
|
404
|
+
if (entry.isDirectory()) {
|
|
405
|
+
if (!entry.name.startsWith(".") && !SKIPPED_DIRECTORIES.has(entry.name)) await walk(root, relative, files);
|
|
406
|
+
} else if (entry.isFile() && SOURCE_FILE.test(entry.name) && !SKIPPED_FILE.test(entry.name)) {
|
|
407
|
+
files.push(relative);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// The installed @jskit-ai packages outside the app's own tree (a workspace's linked packages are read as app source),
|
|
413
|
+
// each as { directory, real }: where the app sees it and where it is.
|
|
414
|
+
async function frameworkPackages(root) {
|
|
415
|
+
let entries;
|
|
416
|
+
try {
|
|
417
|
+
entries = await readdir(path.join(root, FRAMEWORK_DIRECTORY), { withFileTypes: true });
|
|
418
|
+
} catch (error) {
|
|
419
|
+
if (error?.code === "ENOENT") return [];
|
|
420
|
+
throw error;
|
|
421
|
+
}
|
|
422
|
+
const packages = [];
|
|
423
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
424
|
+
const directory = path.join(FRAMEWORK_DIRECTORY, entry.name);
|
|
425
|
+
const real = await realpath(path.join(root, directory)).catch(() => "");
|
|
426
|
+
const inside = path.relative(root, real);
|
|
427
|
+
const appSource = real && !inside.startsWith("..") && !path.isAbsolute(inside) && !inside.split(path.sep).includes("node_modules");
|
|
428
|
+
if (real && !appSource) packages.push({ directory, real });
|
|
429
|
+
}
|
|
430
|
+
return packages;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Every table resource of a table, and of a name, that another one shares: each one is reported, naming the others.
|
|
434
|
+
function checkTables(resources, report) {
|
|
435
|
+
const where = (resource) => `${resource.file}:${resource.line}`;
|
|
436
|
+
const groups = { tableName: new Map(), name: new Map() };
|
|
437
|
+
for (const resource of resources) {
|
|
438
|
+
if (resource.name === null || resource.tableName === null) {
|
|
439
|
+
if (!resource.framework) {
|
|
440
|
+
report(resource, "data/one-resource-per-table",
|
|
441
|
+
"defineTableResource() does not write its name and tableName as string literals (or consts holding them), so this check cannot see which table it owns.");
|
|
442
|
+
}
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
for (const key of ["tableName", "name"]) {
|
|
446
|
+
groups[key].set(resource[key], [...(groups[key].get(resource[key]) || []), resource]);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
for (const [key, byValue] of Object.entries(groups)) {
|
|
450
|
+
for (const [value, group] of byValue) {
|
|
451
|
+
for (const resource of group.length > 1 ? group : []) {
|
|
452
|
+
const others = group.filter((other) => other !== resource).map(where).join(", ");
|
|
453
|
+
report(resource, "data/one-resource-per-table", key === "tableName"
|
|
454
|
+
? `table "${value}" has another table resource at ${others}; a table has exactly one table resource, owned by one feature.`
|
|
455
|
+
: `resource name "${value}" is also the name of the table resource at ${others}; every table resource has its own name.`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function checkSystemScopes(scopes, report) {
|
|
462
|
+
for (const scope of scopes) {
|
|
463
|
+
if (scope.reason === null && !scope.framework) {
|
|
464
|
+
report(scope, "data/system-scope", scope.kind === "feature"
|
|
465
|
+
? "systemScope is not a string literal; write the reason this feature reads past ownership where it is declared."
|
|
466
|
+
: "systemContext() does not take its reason as a string literal; write why this call reads past ownership at the call.");
|
|
467
|
+
} else if (scope.reason !== null && scope.reason.trim() === "") {
|
|
468
|
+
report(scope, "data/system-scope", scope.kind === "feature"
|
|
469
|
+
? "systemScope gives no reason; say why this feature reads past ownership."
|
|
470
|
+
: "systemContext() gives no reason; say why this call reads past ownership.");
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Reads the app's source and its installed @jskit-ai packages for table resources, system scopes and infrastructure
|
|
477
|
+
* exceptions.
|
|
478
|
+
* @param {{ projectRoot?: string }} options
|
|
479
|
+
*/
|
|
480
|
+
async function checkDataAccess({ projectRoot = process.cwd() } = {}) {
|
|
481
|
+
const root = path.resolve(projectRoot);
|
|
482
|
+
const sources = [];
|
|
483
|
+
const appFiles = [];
|
|
484
|
+
await walk(root, "", appFiles);
|
|
485
|
+
sources.push(...appFiles.map((file) => ({ file, absolute: path.join(root, file), framework: false })));
|
|
486
|
+
for (const { directory, real } of await frameworkPackages(root)) {
|
|
487
|
+
const files = [];
|
|
488
|
+
await walk(real, "", files);
|
|
489
|
+
sources.push(...files.map((file) => ({ file: path.join(directory, file), absolute: path.join(real, file), framework: true })));
|
|
490
|
+
}
|
|
491
|
+
const resources = [];
|
|
492
|
+
const contracts = [];
|
|
493
|
+
const scopes = [];
|
|
494
|
+
const excuses = [];
|
|
495
|
+
const findings = [];
|
|
496
|
+
const report = ({ file, line }, rule, message) => findings.push({ file, line, rule, message });
|
|
497
|
+
for (const { file, absolute, framework } of sources) {
|
|
498
|
+
const source = await readFile(absolute, "utf8");
|
|
499
|
+
if (!MARKERS.test(source)) continue;
|
|
500
|
+
const name = toPosixPath(file);
|
|
501
|
+
const read = readSourceFile(source);
|
|
502
|
+
resources.push(...readTableResources(name, read).map((resource) => ({ ...resource, framework })));
|
|
503
|
+
scopes.push(...readSystemScopes(name, read).map((scope) => ({ ...scope, framework })));
|
|
504
|
+
if (framework) continue;
|
|
505
|
+
contracts.push(...readCrudContracts(read));
|
|
506
|
+
const fileExcuses = readInfrastructureExcuses(name, source);
|
|
507
|
+
excuses.push(...fileExcuses);
|
|
508
|
+
if (fileExcuses.length < 1) {
|
|
509
|
+
for (const use of readRuntimeCapabilities(name, read)) {
|
|
510
|
+
report(use, "data/runtime-capability", `requires ${use.capability}`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (CLIENT_FILE.test(name)) continue;
|
|
514
|
+
for (const disable of readLintDisables(name, source, read.position)) {
|
|
515
|
+
report(disable, "data/lint-disabled", disable.rules.length > 0
|
|
516
|
+
? `eslint-disable turns off ${disable.rules.join(", ")}`
|
|
517
|
+
: "eslint-disable names no rule");
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
checkTables(resources, report);
|
|
521
|
+
checkSystemScopes(scopes, report);
|
|
522
|
+
checkBaseline(await readBaseline(root, report), excuses, scopes, report);
|
|
523
|
+
findings.sort((left, right) => left.file.localeCompare(right.file) || left.line - right.line);
|
|
524
|
+
return {
|
|
525
|
+
ok: findings.length < 1,
|
|
526
|
+
findings,
|
|
527
|
+
files: sources.length,
|
|
528
|
+
resources: resources.length,
|
|
529
|
+
systemScopes: scopes.filter((scope) => scope.reason !== null),
|
|
530
|
+
excuses,
|
|
531
|
+
unnamedFields: unnamedFields(resources, contracts)
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function formatDataAccessReport(result) {
|
|
536
|
+
const lines = [];
|
|
537
|
+
// Each rule's explanation and guide section once, then its findings.
|
|
538
|
+
for (const [rule, findings] of Map.groupBy(result.findings, ({ rule }) => rule)) {
|
|
539
|
+
lines.push(`${rule} (${findings.length}): ${RULE_EXPLANATIONS[rule]} ${GUIDE}#${SECTIONS[rule]}`);
|
|
540
|
+
for (const { file, line, message } of findings) lines.push(` ${file}:${line} ${message}`);
|
|
541
|
+
}
|
|
542
|
+
if (result.systemScopes.length > 0) {
|
|
543
|
+
lines.push(`System scopes (${result.systemScopes.length}), each reading past ownership for its reason (data/system-scope; ${GUIDE}#${SECTIONS["data/system-scope"]}):`);
|
|
544
|
+
for (const { file, line, kind, id, reason } of result.systemScopes) {
|
|
545
|
+
lines.push(` ${file}:${line} ${kind === "feature" ? `feature ${id || "(no id)"}` : "systemContext()"} "${reason}"`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
if (result.excuses.length > 0) {
|
|
549
|
+
lines.push(`Infrastructure exceptions (${result.excuses.length}), each a whole file past a data-access lint rule (data/infrastructure-excuses; ${GUIDE}#${SECTIONS["data/infrastructure-excuses"]}):`);
|
|
550
|
+
for (const { file, line, rule, reason } of result.excuses) lines.push(` ${file}:${line} ${rule} ${reason}`);
|
|
551
|
+
}
|
|
552
|
+
if (result.unnamedFields.length > 0) {
|
|
553
|
+
lines.push(`Fields no CRUD contract names (${result.unnamedFields.length} table resource(s)), so no API shows or accepts them; ` +
|
|
554
|
+
`name one in a contract's fields to offer it (data/unnamed-fields; ${GUIDE}#${SECTIONS["data/unnamed-fields"]}):`);
|
|
555
|
+
for (const { file, line, name, fields } of result.unnamedFields) lines.push(` ${file}:${line} ${name}: ${fields.join(", ")}`);
|
|
556
|
+
}
|
|
557
|
+
const listed = `${result.systemScopes.length} system scope(s) and ${result.excuses.length} infrastructure exception(s) listed for review`;
|
|
558
|
+
lines.push(result.ok
|
|
559
|
+
? `JSKIT data access: ${result.resources} table resource(s), each table and name defined once; ${listed}.`
|
|
560
|
+
: `JSKIT data access: ${result.findings.length} error(s). Each finding says what to change; a new infrastructure exception or system scope passes once reviewed and listed in ${BASELINE_FILE}; ${listed}.`);
|
|
561
|
+
return lines.join("\n");
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export { checkDataAccess, formatDataAccessReport };
|
package/scripts/jskit.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
formatCheckReport,
|
|
12
12
|
formatExportSummary
|
|
13
13
|
} from "./i18n.mjs";
|
|
14
|
+
import { checkDataAccess, formatDataAccessReport } from "./data-access.mjs";
|
|
14
15
|
import { checkMaterial3, formatMaterial3Report, formatNavigationReport } from "./material3.mjs";
|
|
15
16
|
import { checkProject, updateProject } from "./project-packages.mjs";
|
|
16
17
|
import { runTests } from "./test-command.mjs";
|
|
@@ -45,7 +46,11 @@ function usage() {
|
|
|
45
46
|
" workers do not come from testConcurrency() of @jskit-ai/jskit-catalog/test-budget. A Playwright",
|
|
46
47
|
" config with a webServer uses browserTestConfig() of @jskit-ai/jskit-catalog/browser-tests (one",
|
|
47
48
|
" production-build server and database per worker), never a development server, and every one",
|
|
48
|
-
" has a globalTimeout.",
|
|
49
|
+
" has a globalTimeout. It fails when two table resources (defineTableResource) of the app and its",
|
|
50
|
+
" installed JSKIT packages share a table or a name (data/one-resource-per-table), or when a system",
|
|
51
|
+
" scope (a feature's systemScope, a systemContext() call) gives no literal reason (data/system-scope),",
|
|
52
|
+
" and it lists every system scope and every file the infrastructure exception lifts past the",
|
|
53
|
+
" data-access lint rules for review.",
|
|
49
54
|
" check m3 List application UI source that departs from the JSKIT Material 3 guide, each finding with its",
|
|
50
55
|
" guide section: the whole app, or only the given files and directories (a section, or a task's",
|
|
51
56
|
" changed files). Its visual rules (m3/*) are a report; its nav/* rules are plain jskit check's.",
|
|
@@ -231,6 +236,9 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
231
236
|
additionalManifests: options.additionalManifests,
|
|
232
237
|
install: options.install
|
|
233
238
|
});
|
|
239
|
+
for (const { packageId, replacedBy } of result.retiredPackages) {
|
|
240
|
+
process.stdout.write(`Removed ${packageId}, which this release retired: ${replacedBy} replaced it.\n`);
|
|
241
|
+
}
|
|
234
242
|
if (result.changedFiles.length < 1) {
|
|
235
243
|
process.stdout.write(`JSKIT manifests already match catalog ${result.catalogVersion}.\n`);
|
|
236
244
|
} else {
|
|
@@ -253,7 +261,10 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
253
261
|
});
|
|
254
262
|
const ui = await checkMaterial3({ projectRoot: options.projectRoot, navigationOnly: !MATERIAL3_GATE });
|
|
255
263
|
const tests = await checkTestRuns({ projectRoot: options.projectRoot, manifests: result.manifests });
|
|
264
|
+
const data = await checkDataAccess({ projectRoot: options.projectRoot });
|
|
265
|
+
// The data rules first: a graph that fails on many pin versions would otherwise bury them.
|
|
256
266
|
const reports = [
|
|
267
|
+
formatDataAccessReport(data),
|
|
257
268
|
result.ok
|
|
258
269
|
? `JSKIT project graph is coordinated across ${result.manifests.length} manifest(s) and ` +
|
|
259
270
|
`${result.installations.length} resolved package installation(s).`
|
|
@@ -261,7 +272,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
261
272
|
MATERIAL3_GATE ? formatMaterial3Report(ui) : formatNavigationReport(ui),
|
|
262
273
|
formatTestRunsReport(tests)
|
|
263
274
|
];
|
|
264
|
-
if (!result.ok || !ui.ok || !tests.ok) {
|
|
275
|
+
if (!result.ok || !ui.ok || !tests.ok || !data.ok) {
|
|
265
276
|
throw new Error(reports.join("\n"));
|
|
266
277
|
}
|
|
267
278
|
process.stdout.write(`${reports.join("\n")}\n`);
|