@jskit-ai/jskit-catalog 0.2.35 → 0.2.37

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.
@@ -0,0 +1,738 @@
1
+ import { readdir, readFile, realpath } from "node:fs/promises";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+ import { toPosixPath } from "./files.mjs";
6
+ import { blankJsComments, lineIndex } from "./material3-source.mjs";
7
+
8
+ // The data part of plain `jskit check`, read from source, never run. It backs up what the lint rules cannot see across
9
+ // files: every table has exactly one table resource (`defineTableResource({ name, tableName })`) across the app and its
10
+ // installed @jskit-ai packages, so a duplicate name or table fails here before the boot-time throw; every system scope
11
+ // (a feature's `systemScope: "<reason>"` and every `systemContext("<reason>")` call) is listed with its reason, and one
12
+ // without a literal reason fails; and every file the infrastructure exception lifts past the data-access lint rules is
13
+ // listed for review. In the app's own code, which no lint directive can hide from it, a feature's system scope and an
14
+ // infrastructure exception fail until the checked-in BASELINE_FILE lists them, so each new one is reviewed; a module
15
+ // that requires a capability handing out power past the owners (CROSS_OWNER_CAPABILITIES) fails until it is reviewed
16
+ // too; an eslint-disable or inline eslint config that turns off a data-access rule, or every rule, fails in any file
17
+ // but the client's; and so does an app file the app's own ESLint config lints without every data-access rule as an
18
+ // error, or ignores though it is server code. As information, it lists the fields of each app table resource that its CRUD contracts never name,
19
+ // which no API shows or accepts.
20
+
21
+ const GUIDE = "https://mobily-enterprises.github.io/jskit-ai/guide/framework/data-access";
22
+ const SECTIONS = Object.freeze({
23
+ "data/one-resource-per-table": "layer-1-the-table-resource",
24
+ "data/system-scope": "background-work",
25
+ "data/infrastructure-excuses": "hard-rules",
26
+ "data/baseline": "hard-rules",
27
+ "data/runtime-capability": "the-resource-is-the-only-door",
28
+ "data/lint-disabled": "hard-rules",
29
+ "data/lint-config": "hard-rules",
30
+ "data/unnamed-fields": "layer-2-the-crud-surface"
31
+ });
32
+ // What each rule asks, said once above its findings in the report.
33
+ const RULE_EXPLANATIONS = Object.freeze({
34
+ "data/one-resource-per-table": "every table has exactly one table resource, under a name no other resource has.",
35
+ "data/system-scope": "every system scope and systemContext() call gives its reason as a string literal.",
36
+ "data/baseline": "each infrastructure exception and feature system scope of the app is reviewed, then listed in the checked-in " +
37
+ "jskit-data-access.json; review each one below, then add the entry it shows.",
38
+ "data/runtime-capability": "a module that requires a capability handing out power past the owners reaches rows past the " +
39
+ "features that own them. A feature owns its tables with owns and gets their handles; only a module whose job is data " +
40
+ "access itself, declared with the infrastructure exception, takes runtime.database or runtime.resources, and a module " +
41
+ "takes any other one once the baseline's systemScopes lists it with its reason.",
42
+ "data/lint-disabled": "neither eslint-disable nor an inline eslint config comment can turn off a data-access rule, and a " +
43
+ "disable that names no rule turns them all off. Change the code, or name the rules the directive turns off.",
44
+ "data/lint-config": "the app's ESLint config makes every data-access rule an error in each server file, and ignores no " +
45
+ "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
+ "once the module's whole job is data access, list { \"file\", \"rule\" } in the baseline's \"infrastructure\"."
47
+ });
48
+ // The reviewed list of the app's infrastructure exceptions and feature system scopes, at the app's root.
49
+ const BASELINE_FILE = "jskit-data-access.json";
50
+ const DATA_ACCESS_RULES = Object.freeze(["no-direct-knex", "no-transaction-reader", "sql-literal", "no-hand-scope", "no-hand-context", "knex-reason"]);
51
+ // An eslint-disable directive and the rules it names, before any "--" description.
52
+ const LINT_DISABLE = /\/[/*]\s*eslint-disable(?:-next-line|-line)?(?![\w-])([^\n]*)/gu;
53
+ // An inline config comment's text, `eslint jskit/no-direct-knex: "off"`, and the rules it sets.
54
+ const LINT_CONFIG = /^\s*eslint\s([\s\S]*)$/u;
55
+ // The capabilities that hand a module power past the features owning the rows, with what each gives: one list. Requiring
56
+ // runtime.database or runtime.resources is for a module declared infrastructure only; requiring any other one passes
57
+ // once the baseline's systemScopes lists it, with its reason: { "file", "capability", "systemScope": "<reason>" }.
58
+ const CROSS_OWNER_CAPABILITIES = Object.freeze({
59
+ "runtime.database": { gives: "the database, past every owner", infrastructureOnly: true },
60
+ "runtime.resources": { gives: "every table resource's handle", infrastructureOnly: true },
61
+ "realtime.audience-scope": { gives: "contexts for any workspace and user (forWorkspace, forUser, forWorkspaceUser)" },
62
+ "auth.local.accounts": { gives: "every local sign-in account, read and written in local auth's system context" },
63
+ "auth.local.sessions": { gives: "every sign-in session, read and written in local auth's system context" },
64
+ "auth.local.recovery": { gives: "every recovery token, read and written in local auth's system context" },
65
+ "connectors.store": { gives: "every owner's connections, with their credentials decrypted" },
66
+ "assistant.repositories": { gives: "every actor's and workspace's assistant conversations, messages and settings" },
67
+ "payments.store": { gives: "every merchant's payment accounts, customers and catalogues" }
68
+ });
69
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
70
+ // A capability id written as a string, compared as the kernel resolves it: trimmed and in any case.
71
+ const CAPABILITY_NAMES = Object.keys(CROSS_OWNER_CAPABILITIES).map(escapeRegExp).join("|");
72
+ const CROSS_OWNER_CAPABILITY = new RegExp(`(["'\`])\\s*(${CAPABILITY_NAMES})\\s*\\1`, "giu");
73
+ // The root of the app or of one of its packages, where a folder's name says what the whole tree is: a migrations/ or
74
+ // test/ folder deeper in server code is server code (as in @jskit-ai/config-eslint's dataAccess.js).
75
+ const ROOT = String.raw`(?:(?:packages|tooling)/[^/]+/)?`;
76
+ // Client code, which the data-access rules leave alone: a package's src/client. An app's own src/ is linted whole, since
77
+ // server code sits there too.
78
+ const CLIENT_FILE = new RegExp(`^${ROOT}src/client/`, "u");
79
+ // What the data-access lint leaves alone (NOT_DATA_ACCESS_SOURCE in @jskit-ai/config-eslint's dataAccess.js), for a path
80
+ // from the directory of the ESLint config that governs the file, a nested application's own included: client code,
81
+ // tests, migrations, seeds and build output at a root, seed scripts, and test files.
82
+ const LINT_EXEMPT = new RegExp(`^${ROOT}(?:(?:src/client|src/test|test|tests|__tests__|e2e|test-support|migrations|seeds?|` +
83
+ "dist|build|coverage)/|(?:(?:scripts|bin)/)?seed[^/]*\\.[cm]?js$)|\\.(?:test|spec|vitest)\\.[^/]*$", "u");
84
+ // Server code, which the app's ESLint config may not ignore: a package's src/server or src/shared, or the app's server/.
85
+ const SERVER_FILE = new RegExp(`^(?:${ROOT}src/(?:server|shared)/|server/)`, "u");
86
+ const FRAMEWORK_DIRECTORY = path.join("node_modules", "@jskit-ai");
87
+ const SOURCE_FILE = /\.[cm]?js$/u;
88
+ const SKIPPED_FILE = /\.(?:test|spec|vitest)\.[cm]?js$/u;
89
+ // Code that defines no table resource or system scope an app runs: dependencies, anywhere; and, at a root (ROOT), build
90
+ // output, tests and fixtures, migrations and seeds, and the examples, templates and documentation JSKIT packages carry.
91
+ const SKIPPED_ROOT_DIRECTORIES = new Set([
92
+ "dist", "build", "coverage", "test", "tests", "__tests__", "e2e", "fixtures", "test-results", "migrations", "seed",
93
+ "seeds", "templates", "patterns", "examples", "docs", "site", "skills", "test-support", "src/test"
94
+ ]);
95
+ const PACKAGE_DIRECTORY = /^(?:packages|tooling)\/[^/]+\/(.+)$/u;
96
+ const skipsDirectory = (name) => path.posix.basename(name) === "node_modules" ||
97
+ SKIPPED_ROOT_DIRECTORIES.has(PACKAGE_DIRECTORY.exec(name)?.[1] ?? name);
98
+ const MARKERS = new RegExp(`defineTableResource|defineCrudContract|systemScope|systemContext|readable-check: allow|eslint|${CAPABILITY_NAMES}`, "iu");
99
+ const TABLE_RESOURCE_CALL = /\bdefineTableResource\s*\(/gu;
100
+ const TABLE_RESOURCE_MARKER = /\bdefineTableResource\s*\(/u;
101
+ // A CRUD contract on a table resource named by its variable: `defineCrudContract(receivalResource, { ... })`.
102
+ const CRUD_CONTRACT_CALL = /\bdefineCrudContract\s*\(\s*([A-Za-z_$][\w$]*)\s*,\s*/gu;
103
+ // The fields an ownership kind keeps for itself: json-rest-api fills them from the context.
104
+ const OWNERSHIP_FIELDS = Object.freeze({
105
+ workspace: ["workspaceId"],
106
+ workspace_or_shared: ["workspaceId"],
107
+ user: ["userId"],
108
+ workspace_user: ["workspaceId", "userId"]
109
+ });
110
+ // The stamped timestamps every contract shows without an entry.
111
+ const STAMPED_FIELDS = Object.freeze(["createdAt", "updatedAt"]);
112
+ const FEATURE_CALL = /\bdefine(?:Crud)?Feature\s*\(/gu;
113
+ const SYSTEM_CONTEXT_CALL = /\bsystemContext\s*\(/gu;
114
+ const INFRASTRUCTURE_EXCUSE = /readable-check: allow (?:jskit\/)?(no-direct-knex|no-transaction-reader|no-hand-scope|sql-literal) - (infrastructure:.*?)\s*(?:\*\/)?\s*$/u;
115
+
116
+ const blank = (text) => text.replace(/[^\n]/gu, " ");
117
+
118
+ // Code with its comments and the contents of its strings and templates blanked to spaces, every offset kept: calls
119
+ // and keys are found in it, values are read from the code at the same offsets.
120
+ function maskStrings(code) {
121
+ let output = "";
122
+ let index = 0;
123
+ while (index < code.length) {
124
+ const quote = code[index];
125
+ if (quote !== "\"" && quote !== "'" && quote !== "`") {
126
+ output += quote;
127
+ index += 1;
128
+ continue;
129
+ }
130
+ let end = index + 1;
131
+ while (end < code.length && code[end] !== quote && (quote === "`" || code[end] !== "\n")) {
132
+ end += code[end] === "\\" ? 2 : 1;
133
+ }
134
+ output += quote + blank(code.slice(index + 1, end)) + code.slice(end, end + 1);
135
+ index = end + 1;
136
+ }
137
+ return output;
138
+ }
139
+
140
+ // The offset just past the bracket that closes the one at `start`, in masked code; -1 when it never closes.
141
+ function closingOffset(masked, start) {
142
+ let depth = 0;
143
+ for (let index = start; index < masked.length; index += 1) {
144
+ if ("([{".includes(masked[index])) depth += 1;
145
+ else if (")]}".includes(masked[index]) && --depth === 0) return index + 1;
146
+ }
147
+ return -1;
148
+ }
149
+
150
+ const nextCodeOffset = (text, offset) => offset + (/^\s*/u.exec(text.slice(offset))?.[0].length || 0);
151
+
152
+ // The string a top-level `const NAME = "..."` gives, or null.
153
+ function constantValue(code, name) {
154
+ const match = new RegExp(`(?:^|[\\s;])const\\s+${name}\\s*=\\s*(["'\`])((?:\\\\.|(?!\\1)[^\\\\\\n])*)\\1\\s*[;,\\n]`, "u").exec(code);
155
+ return match && !(match[1] === "`" && match[2].includes("${")) ? match[2] : null;
156
+ }
157
+
158
+ /**
159
+ * The value written at an offset, read up to the comma or bracket that ends it: { text } for a string literal, a
160
+ * template without expressions, or a const holding one; { text: null } for anything else.
161
+ */
162
+ function readValue(code, masked, offset) {
163
+ const start = nextCodeOffset(code, offset);
164
+ const quote = code[start];
165
+ if (quote === "\"" || quote === "'" || quote === "`") {
166
+ let end = start + 1;
167
+ while (end < code.length && code[end] !== quote) end += code[end] === "\\" ? 2 : 1;
168
+ const after = masked[nextCodeOffset(masked, end + 1)];
169
+ const text = code.slice(start + 1, end);
170
+ const whole = after === "," || after === "}" || after === ")";
171
+ return { text: whole && !(quote === "`" && text.includes("${")) ? text.replace(/\\(.)/gu, "$1") : null };
172
+ }
173
+ const identifier = /^[A-Za-z_$][\w$]*(?=\s*[,})])/u.exec(code.slice(start));
174
+ return { text: identifier ? constantValue(code, identifier[0]) : null };
175
+ }
176
+
177
+ // The top-level keys of the object literal between `start` and `end`, in masked code: for each wanted key, the offset
178
+ // just past its colon, or "shorthand" for `{ name }`.
179
+ function topLevelKeys(masked, start, end, names) {
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 = new Map();
189
+ for (const match of top.matchAll(/(?:^|[{,])\s*([A-Za-z_$][\w$]*)\s*(:|(?=[,}]))/gu)) {
190
+ const [whole, key, colon] = match;
191
+ if (names.includes(key) && !keys.has(key)) keys.set(key, colon ? start + match.index + whole.length : "shorthand");
192
+ }
193
+ return keys;
194
+ }
195
+
196
+ // The object literal a call at `callOffset` takes as its first argument: { start, end } in masked code, or null when the
197
+ // call is a declaration (`function defineFeature(`) or its first argument is anything else.
198
+ function objectArgument(masked, callOffset, matchLength) {
199
+ if (/function\s*\*?\s*$/u.test(masked.slice(Math.max(0, callOffset - 20), callOffset))) return null;
200
+ const start = nextCodeOffset(masked, callOffset + matchLength);
201
+ if (masked[start] !== "{") return null;
202
+ const end = closingOffset(masked, start);
203
+ return end < 0 ? null : { start, end };
204
+ }
205
+
206
+ // The value of one key of an object literal, as readValue reads it; undefined when the object has no such key.
207
+ function keyValue(code, masked, keys, key) {
208
+ const at = keys.get(key);
209
+ if (at === undefined) return undefined;
210
+ return at === "shorthand" ? { text: constantValue(code, key) } : readValue(code, masked, at);
211
+ }
212
+
213
+ function readSourceFile(source) {
214
+ const code = blankJsComments(source);
215
+ return { source, code, masked: maskStrings(code), position: lineIndex(source) };
216
+ }
217
+
218
+ // Every key of the object literal between `start` and `end` (masked code), in order, and whether the literal also
219
+ // spreads or computes keys, so that its keys are not all known.
220
+ function objectKeys(masked, start, end) {
221
+ let depth = 0;
222
+ let top = "";
223
+ for (let index = start; index < end; index += 1) {
224
+ const char = masked[index];
225
+ if ("([{".includes(char)) depth += 1;
226
+ top += depth === 1 || char === "\n" ? char : " ";
227
+ if (")]}".includes(char)) depth -= 1;
228
+ }
229
+ const keys = [...top.matchAll(/(?:^|[{,])\s*([A-Za-z_$][\w$]*)\s*(?::|(?=[,}]))/gu)].map((match) => ({
230
+ key: match[1],
231
+ at: start + match.index + match[0].length
232
+ }));
233
+ return { keys, partial: /(?:^|[{,])\s*(?:\.\.\.|["'`[])/u.test(top) };
234
+ }
235
+
236
+ // The object literal written at an offset: { start, end } in masked code, or null for anything else.
237
+ function objectAt(masked, offset) {
238
+ const start = nextCodeOffset(masked, offset);
239
+ if (masked[start] !== "{") return null;
240
+ const end = closingOffset(masked, start);
241
+ return end < 0 ? null : { start, end };
242
+ }
243
+
244
+ // A table resource's schema fields that a CRUD contract could show: { names, partial }, or null when the schema is
245
+ // not an object literal. The ownership fields, the stamped timestamps, the primary key and hidden fields are left out.
246
+ function readSchemaFields(code, masked, keys, ownership) {
247
+ const at = keys.get("schema");
248
+ const schema = typeof at === "number" ? objectAt(masked, at) : null;
249
+ if (!schema) return null;
250
+ const { keys: fields, partial } = objectKeys(masked, schema.start, schema.end);
251
+ const kept = new Set([...(OWNERSHIP_FIELDS[ownership] || []), ...STAMPED_FIELDS]);
252
+ const names = fields.filter(({ key, at: valueAt }) => {
253
+ if (kept.has(key)) return false;
254
+ const field = objectAt(masked, valueAt);
255
+ if (!field) return true;
256
+ const flags = topLevelKeys(masked, field.start, field.end, ["hidden", "primary"]);
257
+ return ![...flags.values()].some((flagAt) => typeof flagAt === "number" && code.slice(nextCodeOffset(code, flagAt)).startsWith("true"));
258
+ }).map(({ key }) => key);
259
+ return { names, partial };
260
+ }
261
+
262
+ // Every table resource a file defines: its name and table, each null when the source does not write it as a literal,
263
+ // the variable it is assigned to and the schema fields a CRUD contract could show.
264
+ function readTableResources(file, { code, masked, position }) {
265
+ const resources = [];
266
+ for (const match of masked.matchAll(TABLE_RESOURCE_CALL)) {
267
+ const object = objectArgument(masked, match.index, match[0].length);
268
+ if (!object) continue;
269
+ const keys = topLevelKeys(masked, object.start, object.end, ["name", "tableName", "ownership", "schema", "foreignKeys"]);
270
+ const ownership = keyValue(code, masked, keys, "ownership")?.text ?? null;
271
+ resources.push({
272
+ file,
273
+ line: position(match.index).line,
274
+ name: keyValue(code, masked, keys, "name")?.text ?? null,
275
+ tableName: keyValue(code, masked, keys, "tableName")?.text ?? null,
276
+ 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,
277
+ fields: readSchemaFields(code, masked, keys, ownership),
278
+ foreignKeys: keys.has("foreignKeys")
279
+ });
280
+ }
281
+ return resources;
282
+ }
283
+
284
+ // Every CRUD contract a file defines: the variable of its table resource and the fields its `fields` names, null when
285
+ // they are not all written as literal keys.
286
+ function readCrudContracts({ code, masked }) {
287
+ const contracts = [];
288
+ for (const match of masked.matchAll(CRUD_CONTRACT_CALL)) {
289
+ const config = objectAt(masked, match.index + match[0].length);
290
+ if (!config) {
291
+ contracts.push({ resource: match[1], fields: null });
292
+ continue;
293
+ }
294
+ const at = topLevelKeys(masked, config.start, config.end, ["fields"]).get("fields");
295
+ const fields = typeof at === "number" ? objectAt(masked, at) : null;
296
+ const named = fields ? objectKeys(masked, fields.start, fields.end) : null;
297
+ const known = at === undefined || (named && !named.partial);
298
+ contracts.push({ resource: match[1], fields: at === undefined ? [] : known ? named.keys.map(({ key }) => key) : null });
299
+ }
300
+ return contracts;
301
+ }
302
+
303
+ // The fields of each app table resource that its CRUD contracts never name, for a resource with at least one contract
304
+ // whose fields are all known.
305
+ function unnamedFields(resources, contracts) {
306
+ const byResource = Map.groupBy(contracts, ({ resource }) => resource);
307
+ const unnamed = [];
308
+ for (const resource of resources) {
309
+ const own = byResource.get(resource.variable);
310
+ if (resource.framework || !resource.fields || !own || own.some(({ fields }) => fields === null)) continue;
311
+ const named = new Set(own.flatMap(({ fields }) => fields));
312
+ const fields = resource.fields.names.filter((name) => !named.has(name));
313
+ if (fields.length > 0) unnamed.push({ file: resource.file, line: resource.line, name: resource.name, fields });
314
+ }
315
+ return unnamed;
316
+ }
317
+
318
+ // A function of the file's own named systemContext: declared in the file (`function systemContext(`, `const
319
+ // systemContext =`), or handed in under a JSDoc type that is not JSKIT's SystemContext factory, such as
320
+ // `@param {() => { root: string }} dependencies.systemContext`.
321
+ const LOCAL_SYSTEM_CONTEXT = /\bfunction\s*\*?\s*systemContext\s*\(|\b(?:const|let|var)\s+systemContext\s*=/u;
322
+ const PARAM_TYPE = /@param\s*\{/gu;
323
+ const SYSTEM_CONTEXT_PARAM = /^\s*\[?(?:[\w$]+\.)*systemContext\b/u;
324
+
325
+ // Whether the file's systemContext is its own function, not the one JSKIT hands a feature with a systemScope: the
326
+ // check reads JSKIT's binding, not every function of that name.
327
+ function ownsSystemContextName(source, masked) {
328
+ if (LOCAL_SYSTEM_CONTEXT.test(masked)) return true;
329
+ for (const match of source.matchAll(PARAM_TYPE)) {
330
+ const close = closingOffset(source, match.index + match[0].length - 1);
331
+ if (close > 0 && SYSTEM_CONTEXT_PARAM.test(source.slice(close, close + 120))) {
332
+ return !/\bSystemContext\b/u.test(source.slice(match.index, close));
333
+ }
334
+ }
335
+ return false;
336
+ }
337
+
338
+ // Every system scope a file declares or calls: a feature's systemScope and each call of JSKIT's systemContext(...),
339
+ // with its reason, null when it is not a literal.
340
+ function readSystemScopes(file, { source, code, masked, position }) {
341
+ const scopes = [];
342
+ for (const match of masked.matchAll(FEATURE_CALL)) {
343
+ const object = objectArgument(masked, match.index, match[0].length);
344
+ if (!object) continue;
345
+ const keys = topLevelKeys(masked, object.start, object.end, ["id", "systemScope"]);
346
+ const reason = keyValue(code, masked, keys, "systemScope");
347
+ if (reason === undefined) continue;
348
+ scopes.push({ file, line: position(match.index).line, kind: "feature", id: keyValue(code, masked, keys, "id")?.text || "", reason: reason.text });
349
+ }
350
+ const calls = ownsSystemContextName(source, masked) ? [] : masked.matchAll(SYSTEM_CONTEXT_CALL);
351
+ for (const match of calls) {
352
+ const open = match.index + match[0].length - 1;
353
+ const close = closingOffset(masked, open);
354
+ const before = masked.slice(Math.max(0, match.index - 20), match.index);
355
+ // A declaration, not a call: `function systemContext(reason) {` or a method `systemContext(reason) {`.
356
+ if (close < 0 || /function\s*\*?\s*$/u.test(before) || masked[nextCodeOffset(masked, close)] === "{") continue;
357
+ const empty = masked.slice(open + 1, close - 1).trim() === "";
358
+ const reason = empty ? { text: "" } : readValue(code, masked, open + 1);
359
+ scopes.push({ file, line: position(match.index).line, kind: "call", id: "", reason: reason.text });
360
+ }
361
+ return scopes;
362
+ }
363
+
364
+ // The infrastructure exceptions in a file's first lines, before its first line of code.
365
+ function readInfrastructureExcuses(file, source) {
366
+ const header = /^(?:#![^\n]*\n)?(?:\s|\/\/[^\n]*|\/\*[\s\S]*?\*\/)*/u.exec(source)[0];
367
+ return header.split("\n").flatMap((text, index) => {
368
+ const match = INFRASTRUCTURE_EXCUSE.exec(text);
369
+ return match ? [{ file, line: index + 1, rule: match[1], reason: match[2] }] : [];
370
+ });
371
+ }
372
+
373
+ const isDataAccessRule = (rule) => DATA_ACCESS_RULES.includes(rule.replace(/^jskit\//u, ""));
374
+
375
+ // The block comments of a file, each { index, value }, read as blankJsComments reads comments: text in a string or a line
376
+ // comment is no comment. ESLint reads inline config only from block comments.
377
+ function blockComments(source) {
378
+ const comments = [];
379
+ let index = 0;
380
+ while (index < source.length) {
381
+ const opening = source.slice(index, index + 2);
382
+ if (opening === "//" || opening === "/*") {
383
+ const end = source.indexOf(opening === "//" ? "\n" : "*/", index + 2);
384
+ const stop = end < 0 ? source.length : end;
385
+ if (opening === "/*") comments.push({ index, value: source.slice(index + 2, stop) });
386
+ index = opening === "/*" ? stop + 2 : stop;
387
+ } else if ("\"'`".includes(source[index])) {
388
+ const quote = source[index];
389
+ index += 1;
390
+ while (index < source.length && source[index] !== quote && (quote === "`" || source[index] !== "\n")) {
391
+ index += source[index] === "\\" ? 2 : 1;
392
+ }
393
+ index += 1;
394
+ } else {
395
+ index += 1;
396
+ }
397
+ }
398
+ return comments;
399
+ }
400
+
401
+ // 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
402
+ // turns off every rule; and every inline config comment that sets a data-access rule, which can only weaken it.
403
+ function readLintDisables(file, source, position) {
404
+ const disables = [];
405
+ for (const match of source.matchAll(LINT_DISABLE)) {
406
+ const named = match[1].replace(/\*\/.*$/u, "").split(/\s--\s|\s--$/u)[0].split(",").map((rule) => rule.trim()).filter(Boolean);
407
+ const rules = named.filter(isDataAccessRule);
408
+ if (named.length < 1 || rules.length > 0) {
409
+ disables.push({ file, line: position(match.index).line, kind: "disable", rules });
410
+ }
411
+ }
412
+ for (const comment of blockComments(source)) {
413
+ const settings = LINT_CONFIG.exec(comment.value)?.[1].split(/\s--\s/u)[0] || "";
414
+ const rules = [...settings.matchAll(/(?:^|[\s,{])["']?((?:jskit\/)?[a-z][\w-]*)["']?\s*:/gu)].map((rule) => rule[1]).filter(isDataAccessRule);
415
+ if (rules.length > 0) {
416
+ disables.push({ file, line: position(comment.index).line, kind: "config", rules });
417
+ }
418
+ }
419
+ return disables;
420
+ }
421
+
422
+ // Every place a file's code takes a cross-owner capability: each time it names one outside a provides object (the module
423
+ // that provides it), and other than as the id of a feature that provides it in the same file.
424
+ function readRuntimeCapabilities(file, { code, masked, position }) {
425
+ const provides = [...masked.matchAll(/\bprovides\s*:\s*\{/gu)].map((match) => {
426
+ const start = match.index + match[0].length - 1;
427
+ return { start, end: closingOffset(masked, start) };
428
+ });
429
+ const names = [...code.matchAll(CROSS_OWNER_CAPABILITY)].map((match) => ({
430
+ match,
431
+ capability: match[2].toLowerCase(),
432
+ provided: provides.some(({ start, end }) => match.index > start && match.index < end)
433
+ }));
434
+ const provided = new Set(names.filter((name) => name.provided).map(({ capability }) => capability));
435
+ const ownId = ({ match, capability }) => provided.has(capability) &&
436
+ /\bid\s*:\s*$/u.test(masked.slice(Math.max(0, match.index - 12), match.index));
437
+ return names.filter((name) => !name.provided && !ownId(name))
438
+ .map(({ match, capability }) => ({ file, line: position(match.index).line, capability }));
439
+ }
440
+
441
+ // Whether a rule's setting in a computed config is "error", which ESLint gives as 2 or [2, ...options].
442
+ const isError = (setting) => [2, "error"].includes(Array.isArray(setting) ? setting[0] : setting);
443
+
444
+ // The app's ESLint, from the app or else beside this check, set to find each file's config as `eslint` does; null when
445
+ // neither has one, or the app has no config: then the lint itself cannot run, which fails on its own.
446
+ async function appEslint(root) {
447
+ for (const base of [path.join(root, "package.json"), fileURLToPath(import.meta.url)]) {
448
+ let resolved;
449
+ try {
450
+ resolved = createRequire(base).resolve("eslint");
451
+ } catch {
452
+ continue;
453
+ }
454
+ const api = await import(pathToFileURL(resolved).href);
455
+ const eslint = new (api.ESLint || api.default.ESLint)({ cwd: root });
456
+ return await eslint.findConfigFile(path.join(root, "package.json")) ? eslint : null;
457
+ }
458
+ return null;
459
+ }
460
+
461
+ // Fails each app file its ESLint config lints without every data-access rule as an error, and each server file it
462
+ // ignores, unless the baseline's infrastructure lists { file, rule } for each rule that is not on. Each file is judged by
463
+ // the config that governs it, as `eslint` does, and what the rules leave alone (LINT_EXEMPT) is told from that config's
464
+ // directory: a nested application's client code is its own.
465
+ async function checkLintConfig(root, files, baseline, report) {
466
+ const eslint = await appEslint(root);
467
+ if (!eslint) return;
468
+ for (const file of files) {
469
+ const absolute = path.join(root, file);
470
+ let configFile;
471
+ let config;
472
+ try {
473
+ configFile = await eslint.findConfigFile(absolute);
474
+ config = configFile ? await eslint.calculateConfigForFile(absolute) : undefined;
475
+ } catch (error) {
476
+ report({ file, line: 1 }, "data/lint-config", `ESLint cannot read the app's config for this file: ${String(error?.message || error).split("\n")[0]}`);
477
+ return;
478
+ }
479
+ const governed = configFile ? toPosixPath(path.relative(path.dirname(configFile), absolute)) : "";
480
+ if (!configFile || LINT_EXEMPT.test(governed) || (!config && !SERVER_FILE.test(governed))) continue;
481
+ const off = DATA_ACCESS_RULES.filter((rule) => !isError(config?.rules?.[`jskit/${rule}`]) &&
482
+ !baseline.infrastructure.some((item) => item?.file === file && item?.rule === rule));
483
+ if (off.length < 1) continue;
484
+ report({ file, line: 1 }, "data/lint-config", config
485
+ ? `the ESLint config does not make ${off.map((rule) => `jskit/${rule}`).join(", ")} an error here`
486
+ : "the ESLint config ignores this server file, so no data-access rule checks it");
487
+ }
488
+ }
489
+
490
+ // Fails each cross-owner capability a module requires that is not reviewed: runtime.database and runtime.resources
491
+ // outside a module declared infrastructure, and any other one the baseline's systemScopes does not list with a reason.
492
+ function checkCapabilities(baseline, uses, report) {
493
+ for (const { file, line, capability, infrastructure } of uses) {
494
+ const { gives, infrastructureOnly } = CROSS_OWNER_CAPABILITIES[capability];
495
+ if (infrastructureOnly) {
496
+ if (!infrastructure) report({ file, line }, "data/runtime-capability", `requires ${capability}`);
497
+ continue;
498
+ }
499
+ const entry = baseline.systemScopes.find((item) => item?.file === file && item?.capability === capability);
500
+ if (typeof entry?.systemScope !== "string" || !entry.systemScope.trim()) {
501
+ report({ file, line }, "data/runtime-capability", `requires ${capability}, which gives ${gives}: once the module must, add ` +
502
+ `${JSON.stringify({ file, capability, systemScope: "<why it needs this>" })} to "systemScopes".`);
503
+ }
504
+ }
505
+ }
506
+
507
+ // The app's reviewed baseline, or empty lists when it has none; a file that is not that JSON is a finding.
508
+ async function readBaseline(root, report) {
509
+ let text;
510
+ try {
511
+ text = await readFile(path.join(root, BASELINE_FILE), "utf8");
512
+ } catch (error) {
513
+ if (error?.code === "ENOENT") return { infrastructure: [], systemScopes: [] };
514
+ throw error;
515
+ }
516
+ try {
517
+ const baseline = JSON.parse(text);
518
+ return {
519
+ infrastructure: Array.isArray(baseline.infrastructure) ? baseline.infrastructure : [],
520
+ systemScopes: Array.isArray(baseline.systemScopes) ? baseline.systemScopes : []
521
+ };
522
+ } catch {
523
+ report({ file: BASELINE_FILE, line: 1 }, "data/baseline", `${BASELINE_FILE} is not JSON; it lists { "infrastructure": [{ "file", "rule" }], "systemScopes": [{ "feature", "systemScope" }, { "file", "capability", "systemScope" }] }.`);
524
+ return { infrastructure: [], systemScopes: [] };
525
+ }
526
+ }
527
+
528
+ // Fails each infrastructure exception and feature system scope of the app's own code that the baseline does not list.
529
+ function checkBaseline(baseline, excuses, scopes, report) {
530
+ const listed = (list, entry) => list.some((item) => Object.entries(entry).every(([key, value]) => item?.[key] === value));
531
+ for (const { file, line, rule } of excuses) {
532
+ if (!listed(baseline.infrastructure, { file, rule })) {
533
+ 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".`);
534
+ }
535
+ }
536
+ for (const { file, line, kind, id, reason, framework } of scopes) {
537
+ if (framework || kind !== "feature" || reason === null || !reason.trim()) continue;
538
+ if (!listed(baseline.systemScopes, { feature: id, systemScope: reason })) {
539
+ 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".`);
540
+ }
541
+ }
542
+ }
543
+
544
+ async function walk(root, directory, files) {
545
+ let entries;
546
+ try {
547
+ entries = await readdir(path.join(root, directory), { withFileTypes: true });
548
+ } catch (error) {
549
+ if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return;
550
+ throw error;
551
+ }
552
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
553
+ const relative = directory ? path.join(directory, entry.name) : entry.name;
554
+ if (entry.isDirectory()) {
555
+ if (!entry.name.startsWith(".") && !skipsDirectory(toPosixPath(relative))) await walk(root, relative, files);
556
+ } else if (entry.isFile() && SOURCE_FILE.test(entry.name) && !SKIPPED_FILE.test(entry.name)) {
557
+ files.push(relative);
558
+ }
559
+ }
560
+ }
561
+
562
+ // The installed @jskit-ai packages outside the app's own tree (a workspace's linked packages are read as app source),
563
+ // each as { directory, real }: where the app sees it and where it is.
564
+ async function frameworkPackages(root) {
565
+ let entries;
566
+ try {
567
+ entries = await readdir(path.join(root, FRAMEWORK_DIRECTORY), { withFileTypes: true });
568
+ } catch (error) {
569
+ if (error?.code === "ENOENT") return [];
570
+ throw error;
571
+ }
572
+ const packages = [];
573
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
574
+ const directory = path.join(FRAMEWORK_DIRECTORY, entry.name);
575
+ const real = await realpath(path.join(root, directory)).catch(() => "");
576
+ const inside = path.relative(root, real);
577
+ const appSource = real && !inside.startsWith("..") && !path.isAbsolute(inside) && !inside.split(path.sep).includes("node_modules");
578
+ if (real && !appSource) packages.push({ directory, real });
579
+ }
580
+ return packages;
581
+ }
582
+
583
+ // Every table resource of a table, and of a name, that another one shares: each one is reported, naming the others.
584
+ function checkTables(resources, report) {
585
+ const where = (resource) => `${resource.file}:${resource.line}`;
586
+ const groups = { tableName: new Map(), name: new Map() };
587
+ for (const resource of resources) {
588
+ if (resource.name === null || resource.tableName === null) {
589
+ if (!resource.framework) {
590
+ report(resource, "data/one-resource-per-table",
591
+ "defineTableResource() does not write its name and tableName as string literals (or consts holding them), so this check cannot see which table it owns.");
592
+ }
593
+ continue;
594
+ }
595
+ for (const key of ["tableName", "name"]) {
596
+ groups[key].set(resource[key], [...(groups[key].get(resource[key]) || []), resource]);
597
+ }
598
+ }
599
+ for (const [key, byValue] of Object.entries(groups)) {
600
+ for (const [value, group] of byValue) {
601
+ for (const resource of group.length > 1 ? group : []) {
602
+ const others = group.filter((other) => other !== resource).map(where).join(", ");
603
+ report(resource, "data/one-resource-per-table", key === "tableName"
604
+ ? `table "${value}" has another table resource at ${others}; a table has exactly one table resource, owned by one feature.`
605
+ : `resource name "${value}" is also the name of the table resource at ${others}; every table resource has its own name.`);
606
+ }
607
+ }
608
+ }
609
+ }
610
+
611
+ function checkSystemScopes(scopes, report) {
612
+ for (const scope of scopes) {
613
+ if (scope.reason === null && !scope.framework) {
614
+ report(scope, "data/system-scope", scope.kind === "feature"
615
+ ? "systemScope is not a string literal; write the reason this feature reads past ownership where it is declared."
616
+ : "systemContext() does not take its reason as a string literal; write why this call reads past ownership at the call.");
617
+ } else if (scope.reason !== null && scope.reason.trim() === "") {
618
+ report(scope, "data/system-scope", scope.kind === "feature"
619
+ ? "systemScope gives no reason; say why this feature reads past ownership."
620
+ : "systemContext() gives no reason; say why this call reads past ownership.");
621
+ }
622
+ }
623
+ }
624
+
625
+ // The app's source files and those of its installed @jskit-ai packages (framework: true), each { file, absolute }.
626
+ async function sourceFiles(root) {
627
+ const appFiles = [];
628
+ await walk(root, "", appFiles);
629
+ const sources = appFiles.map((file) => ({ file, absolute: path.join(root, file), framework: false }));
630
+ for (const { directory, real } of await frameworkPackages(root)) {
631
+ const files = [];
632
+ await walk(real, "", files);
633
+ sources.push(...files.map((file) => ({ file: path.join(directory, file), absolute: path.join(real, file), framework: true })));
634
+ }
635
+ return sources;
636
+ }
637
+
638
+ /**
639
+ * The table resources the app and its installed @jskit-ai packages define, read from source as checkDataAccess reads
640
+ * them: each { file, absolute, framework, name, tableName, foreignKeys }, `foreignKeys` true when the definition
641
+ * declares them. `jskit test --databases` checks the declared keys against the database.
642
+ * @param {{ projectRoot?: string }} options
643
+ */
644
+ async function readTableResourceSources({ projectRoot = process.cwd() } = {}) {
645
+ const resources = [];
646
+ for (const { file, absolute, framework } of await sourceFiles(path.resolve(projectRoot))) {
647
+ const source = await readFile(absolute, "utf8");
648
+ if (!TABLE_RESOURCE_MARKER.test(source)) continue;
649
+ const read = readSourceFile(source);
650
+ resources.push(...readTableResources(toPosixPath(file), read).map(({ file: name, line, name: resourceName, tableName, foreignKeys }) =>
651
+ ({ file: name, line, absolute, framework, name: resourceName, tableName, foreignKeys })));
652
+ }
653
+ return resources;
654
+ }
655
+
656
+ /**
657
+ * Reads the app's source and its installed @jskit-ai packages for table resources, system scopes and infrastructure
658
+ * exceptions.
659
+ * @param {{ projectRoot?: string }} options
660
+ */
661
+ async function checkDataAccess({ projectRoot = process.cwd() } = {}) {
662
+ const root = path.resolve(projectRoot);
663
+ const sources = await sourceFiles(root);
664
+ const resources = [];
665
+ const contracts = [];
666
+ const scopes = [];
667
+ const excuses = [];
668
+ const capabilities = [];
669
+ const findings = [];
670
+ const report = ({ file, line }, rule, message) => findings.push({ file, line, rule, message });
671
+ for (const { file, absolute, framework } of sources) {
672
+ const source = await readFile(absolute, "utf8");
673
+ if (!MARKERS.test(source)) continue;
674
+ const name = toPosixPath(file);
675
+ const read = readSourceFile(source);
676
+ resources.push(...readTableResources(name, read).map((resource) => ({ ...resource, framework })));
677
+ scopes.push(...readSystemScopes(name, read).map((scope) => ({ ...scope, framework })));
678
+ if (framework) continue;
679
+ contracts.push(...readCrudContracts(read));
680
+ const fileExcuses = readInfrastructureExcuses(name, source);
681
+ excuses.push(...fileExcuses);
682
+ capabilities.push(...readRuntimeCapabilities(name, read).map((use) => ({ ...use, infrastructure: fileExcuses.length > 0 })));
683
+ if (CLIENT_FILE.test(name)) continue;
684
+ for (const disable of readLintDisables(name, source, read.position)) {
685
+ report(disable, "data/lint-disabled", disable.kind === "config"
686
+ ? `an inline eslint config sets ${disable.rules.join(", ")}`
687
+ : disable.rules.length > 0 ? `eslint-disable turns off ${disable.rules.join(", ")}` : "eslint-disable names no rule");
688
+ }
689
+ }
690
+ checkTables(resources, report);
691
+ checkSystemScopes(scopes, report);
692
+ const baseline = await readBaseline(root, report);
693
+ checkBaseline(baseline, excuses, scopes, report);
694
+ checkCapabilities(baseline, capabilities, report);
695
+ const appFiles = sources.filter((source) => !source.framework).map((source) => toPosixPath(source.file));
696
+ await checkLintConfig(root, appFiles, baseline, report);
697
+ findings.sort((left, right) => left.file.localeCompare(right.file) || left.line - right.line);
698
+ return {
699
+ ok: findings.length < 1,
700
+ findings,
701
+ files: sources.length,
702
+ resources: resources.length,
703
+ systemScopes: scopes.filter((scope) => scope.reason !== null),
704
+ excuses,
705
+ unnamedFields: unnamedFields(resources, contracts)
706
+ };
707
+ }
708
+
709
+ function formatDataAccessReport(result) {
710
+ const lines = [];
711
+ // Each rule's explanation and guide section once, then its findings.
712
+ for (const [rule, findings] of Map.groupBy(result.findings, ({ rule }) => rule)) {
713
+ lines.push(`${rule} (${findings.length}): ${RULE_EXPLANATIONS[rule]} ${GUIDE}#${SECTIONS[rule]}`);
714
+ for (const { file, line, message } of findings) lines.push(` ${file}:${line} ${message}`);
715
+ }
716
+ if (result.systemScopes.length > 0) {
717
+ lines.push(`System scopes (${result.systemScopes.length}), each reading past ownership for its reason (data/system-scope; ${GUIDE}#${SECTIONS["data/system-scope"]}):`);
718
+ for (const { file, line, kind, id, reason } of result.systemScopes) {
719
+ lines.push(` ${file}:${line} ${kind === "feature" ? `feature ${id || "(no id)"}` : "systemContext()"} "${reason}"`);
720
+ }
721
+ }
722
+ if (result.excuses.length > 0) {
723
+ 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"]}):`);
724
+ for (const { file, line, rule, reason } of result.excuses) lines.push(` ${file}:${line} ${rule} ${reason}`);
725
+ }
726
+ if (result.unnamedFields.length > 0) {
727
+ lines.push(`Fields no CRUD contract names (${result.unnamedFields.length} table resource(s)), so no API shows or accepts them; ` +
728
+ `name one in a contract's fields to offer it (data/unnamed-fields; ${GUIDE}#${SECTIONS["data/unnamed-fields"]}):`);
729
+ for (const { file, line, name, fields } of result.unnamedFields) lines.push(` ${file}:${line} ${name}: ${fields.join(", ")}`);
730
+ }
731
+ const listed = `${result.systemScopes.length} system scope(s) and ${result.excuses.length} infrastructure exception(s) listed for review`;
732
+ lines.push(result.ok
733
+ ? `JSKIT data access: ${result.resources} table resource(s), each table and name defined once; ${listed}.`
734
+ : `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}.`);
735
+ return lines.join("\n");
736
+ }
737
+
738
+ export { checkDataAccess, formatDataAccessReport, readTableResourceSources };