@jskit-ai/jskit-catalog 0.2.36 → 0.2.38

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/jskit-catalog",
3
- "version": "0.2.36",
3
+ "version": "0.2.38",
4
4
  "description": "Published metadata catalog for JSKIT package metadata.",
5
5
  "type": "module",
6
6
  "files": [
@@ -1,5 +1,7 @@
1
1
  import { readdir, readFile, realpath } from "node:fs/promises";
2
+ import { createRequire } from "node:module";
2
3
  import path from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
3
5
  import { toPosixPath } from "./files.mjs";
4
6
  import { blankJsComments, lineIndex } from "./material3-source.mjs";
5
7
 
@@ -10,9 +12,11 @@ import { blankJsComments, lineIndex } from "./material3-source.mjs";
10
12
  // without a literal reason fails; and every file the infrastructure exception lifts past the data-access lint rules is
11
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
12
14
  // 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.
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.
16
20
 
17
21
  const GUIDE = "https://mobily-enterprises.github.io/jskit-ai/guide/framework/data-access";
18
22
  const SECTIONS = Object.freeze({
@@ -22,6 +26,7 @@ const SECTIONS = Object.freeze({
22
26
  "data/baseline": "hard-rules",
23
27
  "data/runtime-capability": "the-resource-is-the-only-door",
24
28
  "data/lint-disabled": "hard-rules",
29
+ "data/lint-config": "hard-rules",
25
30
  "data/unnamed-fields": "layer-2-the-crud-surface"
26
31
  });
27
32
  // What each rule asks, said once above its findings in the report.
@@ -30,33 +35,69 @@ const RULE_EXPLANATIONS = Object.freeze({
30
35
  "data/system-scope": "every system scope and systemContext() call gives its reason as a string literal.",
31
36
  "data/baseline": "each infrastructure exception and feature system scope of the app is reviewed, then listed in the checked-in " +
32
37
  "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
+ "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\"."
38
47
  });
39
48
  // The reviewed list of the app's infrastructure exceptions and feature system scopes, at the app's root.
40
49
  const BASELINE_FILE = "jskit-data-access.json";
41
50
  const DATA_ACCESS_RULES = Object.freeze(["no-direct-knex", "no-transaction-reader", "sql-literal", "no-hand-scope", "no-hand-context", "knex-reason"]);
42
51
  // An eslint-disable directive and the rules it names, before any "--" description.
43
52
  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;
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");
49
86
  const FRAMEWORK_DIRECTORY = path.join("node_modules", "@jskit-ai");
50
87
  const SOURCE_FILE = /\.[cm]?js$/u;
51
88
  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"
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"
57
94
  ]);
58
- const MARKERS = /defineTableResource|defineCrudContract|systemScope|systemContext|readable-check: allow|eslint-disable|runtime\.(?:database|resources)/u;
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");
59
99
  const TABLE_RESOURCE_CALL = /\bdefineTableResource\s*\(/gu;
100
+ const TABLE_RESOURCE_MARKER = /\bdefineTableResource\s*\(/u;
60
101
  // A CRUD contract on a table resource named by its variable: `defineCrudContract(receivalResource, { ... })`.
61
102
  const CRUD_CONTRACT_CALL = /\bdefineCrudContract\s*\(\s*([A-Za-z_$][\w$]*)\s*,\s*/gu;
62
103
  // The fields an ownership kind keeps for itself: json-rest-api fills them from the context.
@@ -225,7 +266,7 @@ function readTableResources(file, { code, masked, position }) {
225
266
  for (const match of masked.matchAll(TABLE_RESOURCE_CALL)) {
226
267
  const object = objectArgument(masked, match.index, match[0].length);
227
268
  if (!object) continue;
228
- const keys = topLevelKeys(masked, object.start, object.end, ["name", "tableName", "ownership", "schema"]);
269
+ const keys = topLevelKeys(masked, object.start, object.end, ["name", "tableName", "ownership", "schema", "foreignKeys"]);
229
270
  const ownership = keyValue(code, masked, keys, "ownership")?.text ?? null;
230
271
  resources.push({
231
272
  file,
@@ -233,7 +274,8 @@ function readTableResources(file, { code, masked, position }) {
233
274
  name: keyValue(code, masked, keys, "name")?.text ?? null,
234
275
  tableName: keyValue(code, masked, keys, "tableName")?.text ?? null,
235
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,
236
- fields: readSchemaFields(code, masked, keys, ownership)
277
+ fields: readSchemaFields(code, masked, keys, ownership),
278
+ foreignKeys: keys.has("foreignKeys")
237
279
  });
238
280
  }
239
281
  return resources;
@@ -328,30 +370,138 @@ function readInfrastructureExcuses(file, source) {
328
370
  });
329
371
  }
330
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
+
331
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
332
- // turns off every rule.
402
+ // turns off every rule; and every inline config comment that sets a data-access rule, which can only weaken it.
333
403
  function readLintDisables(file, source, position) {
334
404
  const disables = [];
335
405
  for (const match of source.matchAll(LINT_DISABLE)) {
336
406
  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, "")));
407
+ const rules = named.filter(isDataAccessRule);
338
408
  if (named.length < 1 || rules.length > 0) {
339
- disables.push({ file, line: position(match.index).line, rules });
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 });
340
417
  }
341
418
  }
342
419
  return disables;
343
420
  }
344
421
 
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.
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.
347
424
  function readRuntimeCapabilities(file, { code, masked, position }) {
348
425
  const provides = [...masked.matchAll(/\bprovides\s*:\s*\{/gu)].map((match) => {
349
426
  const start = match.index + match[0].length - 1;
350
427
  return { start, end: closingOffset(masked, start) };
351
428
  });
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] }));
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
+ }
355
505
  }
356
506
 
357
507
  // The app's reviewed baseline, or empty lists when it has none; a file that is not that JSON is a finding.
@@ -370,7 +520,7 @@ async function readBaseline(root, report) {
370
520
  systemScopes: Array.isArray(baseline.systemScopes) ? baseline.systemScopes : []
371
521
  };
372
522
  } catch {
373
- report({ file: BASELINE_FILE, line: 1 }, "data/baseline", `${BASELINE_FILE} is not JSON; it lists { "infrastructure": [{ "file", "rule" }], "systemScopes": [{ "feature", "systemScope" }] }.`);
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" }] }.`);
374
524
  return { infrastructure: [], systemScopes: [] };
375
525
  }
376
526
  }
@@ -402,7 +552,7 @@ async function walk(root, directory, files) {
402
552
  for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
403
553
  const relative = directory ? path.join(directory, entry.name) : entry.name;
404
554
  if (entry.isDirectory()) {
405
- if (!entry.name.startsWith(".") && !SKIPPED_DIRECTORIES.has(entry.name)) await walk(root, relative, files);
555
+ if (!entry.name.startsWith(".") && !skipsDirectory(toPosixPath(relative))) await walk(root, relative, files);
406
556
  } else if (entry.isFile() && SOURCE_FILE.test(entry.name) && !SKIPPED_FILE.test(entry.name)) {
407
557
  files.push(relative);
408
558
  }
@@ -472,26 +622,50 @@ function checkSystemScopes(scopes, report) {
472
622
  }
473
623
  }
474
624
 
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 = [];
625
+ // The app's source files and those of its installed @jskit-ai packages (framework: true), each { file, absolute }.
626
+ async function sourceFiles(root) {
483
627
  const appFiles = [];
484
628
  await walk(root, "", appFiles);
485
- sources.push(...appFiles.map((file) => ({ file, absolute: path.join(root, file), framework: false })));
629
+ const sources = appFiles.map((file) => ({ file, absolute: path.join(root, file), framework: false }));
486
630
  for (const { directory, real } of await frameworkPackages(root)) {
487
631
  const files = [];
488
632
  await walk(real, "", files);
489
633
  sources.push(...files.map((file) => ({ file: path.join(directory, file), absolute: path.join(real, file), framework: true })));
490
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);
491
664
  const resources = [];
492
665
  const contracts = [];
493
666
  const scopes = [];
494
667
  const excuses = [];
668
+ const capabilities = [];
495
669
  const findings = [];
496
670
  const report = ({ file, line }, rule, message) => findings.push({ file, line, rule, message });
497
671
  for (const { file, absolute, framework } of sources) {
@@ -505,21 +679,21 @@ async function checkDataAccess({ projectRoot = process.cwd() } = {}) {
505
679
  contracts.push(...readCrudContracts(read));
506
680
  const fileExcuses = readInfrastructureExcuses(name, source);
507
681
  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
- }
682
+ capabilities.push(...readRuntimeCapabilities(name, read).map((use) => ({ ...use, infrastructure: fileExcuses.length > 0 })));
513
683
  if (CLIENT_FILE.test(name)) continue;
514
684
  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");
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");
518
688
  }
519
689
  }
520
690
  checkTables(resources, report);
521
691
  checkSystemScopes(scopes, report);
522
- checkBaseline(await readBaseline(root, report), excuses, 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);
523
697
  findings.sort((left, right) => left.file.localeCompare(right.file) || left.line - right.line);
524
698
  return {
525
699
  ok: findings.length < 1,
@@ -561,4 +735,4 @@ function formatDataAccessReport(result) {
561
735
  return lines.join("\n");
562
736
  }
563
737
 
564
- export { checkDataAccess, formatDataAccessReport };
738
+ export { checkDataAccess, formatDataAccessReport, readTableResourceSources };
package/scripts/jskit.mjs CHANGED
@@ -247,8 +247,18 @@ async function main(argv = process.argv.slice(2)) {
247
247
  `${result.changedFiles.map((file) => `- ${file}`).join("\n")}\n`
248
248
  );
249
249
  }
250
+ if (result.addedMigrations.length > 0) {
251
+ process.stdout.write(
252
+ `The update installed ${result.addedMigrations.length} new package migration(s); run npm run db:migrate before ` +
253
+ `the app serves requests, since the new packages read what they create:\n` +
254
+ `${result.addedMigrations.map((entry) => `- ${entry}`).join("\n")}\n`
255
+ );
256
+ }
250
257
  if (!options.install) {
251
- process.stdout.write("Skipped npm install; run it before jskit check.\n");
258
+ process.stdout.write(
259
+ "Skipped npm install; run it before jskit check. If it installs new package migrations, run npm run db:migrate " +
260
+ "before the app serves requests.\n"
261
+ );
252
262
  }
253
263
  return;
254
264
  }
@@ -438,6 +438,32 @@ async function findImports(root, packageIds) {
438
438
  return imports;
439
439
  }
440
440
 
441
+ // The migration files the installed @jskit-ai packages ship (jskit.migrations.directories and their constraints/),
442
+ // each "<package>: <path in the package>", as the app's migrations read them from node_modules.
443
+ async function installedPackageMigrations(root) {
444
+ const scope = path.join(root, "node_modules", JSKIT_SCOPE_PREFIX);
445
+ const names = await readdir(scope).catch((error) => {
446
+ if (error?.code === "ENOENT") return [];
447
+ throw error;
448
+ });
449
+ const migrations = new Set();
450
+ for (const name of names) {
451
+ const packageRoot = path.join(scope, name);
452
+ const manifestPath = path.join(packageRoot, "package.json");
453
+ if (!(await fileExists(manifestPath))) continue;
454
+ const directories = (await readJsonRecord(manifestPath)).value.jskit?.migrations?.directories;
455
+ for (const directory of Array.isArray(directories) ? directories : []) {
456
+ for (const folder of [directory, path.join(directory, "constraints")]) {
457
+ const files = await readdir(path.join(packageRoot, folder)).catch(() => []);
458
+ for (const file of files.filter((entry) => entry.endsWith(".cjs"))) {
459
+ migrations.add(`${JSKIT_SCOPE_PREFIX}${name}: ${toPosixPath(path.join(folder, file))}`);
460
+ }
461
+ }
462
+ }
463
+ }
464
+ return migrations;
465
+ }
466
+
441
467
  /**
442
468
  * Pins a project's JSKIT dependencies to the catalog and installs them; a
443
469
  * failed install or check restores the manifests, lockfile and node_modules.
@@ -544,8 +570,11 @@ async function updateProject({
544
570
 
545
571
  const packageLockPath = path.join(root, "package-lock.json");
546
572
  const packageLockSnapshot = install ? await readOptionalFile(packageLockPath) : null;
573
+ // npm install replaces node_modules, so the migrations the packages shipped are read before it.
574
+ const migrationsBefore = install ? await installedPackageMigrations(root) : new Set();
547
575
  await writeManifestUpdates(updates);
548
576
  let installTransaction = null;
577
+ let addedMigrations = [];
549
578
  try {
550
579
  if (install) {
551
580
  const updatedManifests = await discoverProjectManifests(root, additionalManifests);
@@ -555,6 +584,7 @@ async function updateProject({
555
584
  if (!result.ok) {
556
585
  throw new Error(`JSKIT graph remains inconsistent after npm install:\n- ${result.issues.join("\n- ")}`);
557
586
  }
587
+ addedMigrations = [...await installedPackageMigrations(root)].filter((entry) => !migrationsBefore.has(entry)).sort();
558
588
  await installTransaction.commit();
559
589
  }
560
590
  } catch (error) {
@@ -586,6 +616,7 @@ async function updateProject({
586
616
  updates.map((update) => toPosixPath(path.relative(root, update.manifest.absolutePath)))
587
617
  ),
588
618
  catalogVersion,
619
+ addedMigrations: Object.freeze(addedMigrations),
589
620
  retiredPackages: Object.freeze(retiredPackageIds.map((packageId) => Object.freeze({
590
621
  packageId,
591
622
  replacedBy: RETIRED_PACKAGES[packageId].packageId
@@ -3,6 +3,7 @@ import { createRequire } from "node:module";
3
3
  import path from "node:path";
4
4
  import process from "node:process";
5
5
  import { pathToFileURL } from "node:url";
6
+ import { readTableResourceSources } from "./data-access.mjs";
6
7
 
7
8
  // `jskit test --databases`: each test file running at once gets a database of its own, a copy of TEST_DB_NAME restored
8
9
  // from a schema snapshot, from the application's installed @jskit-ai/database-runtime and its knexfile.js, which loads
@@ -13,6 +14,9 @@ import { pathToFileURL } from "node:url";
13
14
  const OPTION = "--databases";
14
15
  const KNEXFILES = Object.freeze(["knexfile.js", "knexfile.mjs", "knexfile.cjs"]);
15
16
  const TEST_DATABASES = "@jskit-ai/database-runtime/server/testDatabases";
17
+ const RESOURCE_RUNTIME = "@jskit-ai/resource-runtime/server";
18
+ const TABLE_RESOURCES = "@jskit-ai/resource-core/shared/tableResource";
19
+ const GUIDE = "https://mobily-enterprises.github.io/jskit-ai/guide/framework/data-access#layer-1-the-table-resource";
16
20
 
17
21
  /** Runs `load` and puts process.env back as it was, returning what `load` returned and the environment it saw. */
18
22
  async function withOwnEnvironment(load) {
@@ -26,6 +30,76 @@ async function withOwnEnvironment(load) {
26
30
  }
27
31
  }
28
32
 
33
+ // A module's exported table resources: each export, or each item of an exported list or object.
34
+ function exportedTableResources(namespace, isTableResource) {
35
+ return Object.values(namespace).flatMap((value) => {
36
+ if (isTableResource(value)) return [value];
37
+ const items = Array.isArray(value) ? value : value && typeof value === "object" ? Object.values(value) : [];
38
+ return items.filter((item) => isTableResource(item));
39
+ });
40
+ }
41
+
42
+ const keyText = ({ tableName, columns, referencedTableName, referencedColumns }) =>
43
+ `${tableName} (${columns.join(", ")}) -> ${referencedTableName} (${referencedColumns.join(", ")})`;
44
+
45
+ /**
46
+ * Compares the foreignKeys the app's table resources and its @jskit-ai packages' declare with the keys TEST_DB_NAME
47
+ * holds, once a run's databases are ready: json-rest-api relies on a declaration to skip work, so a declared key the
48
+ * database lacks fails the run. A package's table that the database does not have is left out: node_modules can hold
49
+ * a package whose migrations the app never runs (one only a devDependency brings, or one left behind), and the app
50
+ * never reads that table. The app's own tables are always checked. A key of one of the app's own tables that nothing
51
+ * declares is reported, as work json-rest-api could skip. It imports only the modules that declare foreignKeys; an app
52
+ * without the resource runtime has nothing to check.
53
+ * @param {string} cwd
54
+ * @returns {(knex: object) => Promise<string>}
55
+ */
56
+ function foreignKeyCheckFor(cwd) {
57
+ return async (knex) => {
58
+ const appRequire = createRequire(path.join(cwd, "package.json"));
59
+ let runtime;
60
+ let tableResources;
61
+ try {
62
+ [runtime, tableResources] = [appRequire.resolve(RESOURCE_RUNTIME), appRequire.resolve(TABLE_RESOURCES)];
63
+ } catch {
64
+ return "";
65
+ }
66
+ const sources = await readTableResourceSources({ projectRoot: cwd });
67
+ const tables = sources.filter((source) => !source.framework && source.tableName).map((source) => source.tableName);
68
+ const declaringFiles = [...new Set(sources.filter((source) => source.foreignKeys).map((source) => source.absolute))];
69
+ if (tables.length < 1 && declaringFiles.length < 1) return "";
70
+ const { isTableResource } = await import(pathToFileURL(tableResources).href);
71
+ const resources = [];
72
+ const absentPackageTables = new Set();
73
+ for (const file of declaringFiles) {
74
+ const declaring = sources.filter((entry) => entry.absolute === file && entry.foreignKeys);
75
+ const exported = exportedTableResources(await import(pathToFileURL(file).href), isTableResource);
76
+ for (const source of declaring) {
77
+ if (!exported.some((resource) => resource.foreignKeys && resource.name === source.name)) {
78
+ throw new Error(`${source.file}:${source.line} declares foreignKeys on a table resource the module does not export, so ` +
79
+ `jskit test ${OPTION} cannot check them against the database: export the resource.`);
80
+ }
81
+ }
82
+ for (const resource of exported.filter((entry) => entry.foreignKeys)) {
83
+ if (declaring[0].framework && !(await knex.schema.hasTable(resource.tableName))) absentPackageTables.add(resource.tableName);
84
+ else resources.push(resource);
85
+ }
86
+ }
87
+ const { checkForeignKeys } = await import(pathToFileURL(runtime).href);
88
+ const { missing, undeclared } = await checkForeignKeys({ knex, resources: [...new Set(resources)], tables });
89
+ if (missing.length > 0) {
90
+ throw new Error(`jskit test ${OPTION}: ${missing.length} declared foreign key(s) the test database does not hold. ` +
91
+ `json-rest-api relies on a declared key, so a wrong one changes results: fix the declaration or add the key ` +
92
+ `with a migration (${GUIDE}).\n` + missing.map((key) => ` ${key.resource}: ${keyText(key)}`).join("\n"));
93
+ }
94
+ const declared = [...new Set(resources)].reduce((count, resource) => count + resource.foreignKeys.length, 0);
95
+ const checked = `foreign keys: ${declared} declared, all in the database` + (absentPackageTables.size < 1 ? "" :
96
+ ` (${absentPackageTables.size} JSKIT package table(s) the database does not have are not checked)`);
97
+ if (undeclared.length < 1) return checked;
98
+ return `${checked}; ${undeclared.length} database key(s) on the app's tables are not declared, so json-rest-api cannot ` +
99
+ `skip the work they would save (declare each on its table resource's foreignKeys): ${undeclared.map(keyText).join("; ")}`;
100
+ };
101
+ }
102
+
29
103
  /**
30
104
  * The run's databases for `count` files at once, or one file at a time on the connected database.
31
105
  * @param {number} count at most the run's test budget, and no more than its test files
@@ -44,7 +118,7 @@ async function testDatabaseRunFor(count, cwd = process.cwd()) {
44
118
  const { result: { default: knexConfig, testDatabaseSchema: schema }, environment } = await withOwnEnvironment(async () =>
45
119
  import(pathToFileURL(knexfile).href));
46
120
  const { testDatabaseRun } = await import(pathToFileURL(testDatabases).href);
47
- return testDatabaseRun({ knexConfig, environment, count, schema, appRoot: cwd });
121
+ return testDatabaseRun({ knexConfig, environment, count, schema, appRoot: cwd, checkDatabase: foreignKeyCheckFor(cwd) });
48
122
  }
49
123
 
50
- export { OPTION as DATABASES_OPTION, testDatabaseRunFor };
124
+ export { OPTION as DATABASES_OPTION, foreignKeyCheckFor, testDatabaseRunFor };