@jskit-ai/jskit-catalog 0.2.35 → 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/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`);
@@ -1,4 +1,4 @@
1
- import { realpath, rename, rm, writeFile } from "node:fs/promises";
1
+ import { readdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { fileExists, toPosixPath } from "./files.mjs";
4
4
  import { readOptionalFile, restoreProjectFiles, runNpmInstall, writeManifestUpdates } from "./project-install.mjs";
@@ -13,6 +13,16 @@ const DEPENDENCY_FIELDS = Object.freeze([
13
13
  "optionalDependencies"
14
14
  ]);
15
15
  const UPDATE_SCRIPT = "npx --yes @jskit-ai/jskit-catalog@latest update";
16
+ // JSKIT packages a release retired, each with what replaced it. The updater removes their declarations itself, and
17
+ // refuses while the application's own code still imports one, naming the import to use instead.
18
+ const RETIRED_PACKAGES = Object.freeze({
19
+ "@jskit-ai/json-rest-api-core": Object.freeze({
20
+ packageId: "@jskit-ai/resource-runtime",
21
+ importFrom: "@jskit-ai/resource-runtime/server (its types from @jskit-ai/resource-runtime/server/types)"
22
+ })
23
+ });
24
+ const SOURCE_FILE_PATTERN = /\.(?:[cm]?[jt]s|vue)$/u;
25
+ const SKIPPED_DIRECTORIES = new Set(["node_modules", "dist", "build", "coverage"]);
16
26
  const CHECK_SCRIPT = "jskit check";
17
27
 
18
28
  function normalizeCatalog(catalog = {}) {
@@ -395,6 +405,39 @@ async function preparePackageLockForInstall(projectRoot, manifests, packageLockP
395
405
  }
396
406
  }
397
407
 
408
+ /**
409
+ * The application's files, outside node_modules and build output, that import one of the packages.
410
+ * @param {string} root
411
+ * @param {string[]} packageIds
412
+ * @returns {Promise<Map<string, string[]>>} The importing files, relative to root, by package.
413
+ */
414
+ async function findImports(root, packageIds) {
415
+ const imports = new Map();
416
+ const patterns = packageIds.map((packageId) => {
417
+ const name = packageId.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
418
+ return [packageId, new RegExp(`["']${name}(?:/[^"']*)?["']`, "u")];
419
+ });
420
+ const visit = async (directory) => {
421
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
422
+ const absolutePath = path.join(directory, entry.name);
423
+ if (entry.isDirectory()) {
424
+ if (!entry.name.startsWith(".") && !SKIPPED_DIRECTORIES.has(entry.name)) {
425
+ await visit(absolutePath);
426
+ }
427
+ } else if (entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name)) {
428
+ const source = await readFile(absolutePath, "utf8");
429
+ for (const [packageId, pattern] of patterns) {
430
+ if (pattern.test(source)) {
431
+ imports.set(packageId, [...(imports.get(packageId) || []), toPosixPath(path.relative(root, absolutePath))]);
432
+ }
433
+ }
434
+ }
435
+ }
436
+ };
437
+ await visit(root);
438
+ return imports;
439
+ }
440
+
398
441
  /**
399
442
  * Pins a project's JSKIT dependencies to the catalog and installs them; a
400
443
  * failed install or check restores the manifests, lockfile and node_modules.
@@ -414,10 +457,20 @@ async function updateProject({
414
457
  const localPackageNames = collectLocalPackageNames(manifests);
415
458
  const declarations = collectManifestDeclarations(manifests, root)
416
459
  .filter((entry) => !localPackageNames.has(entry.packageId));
460
+ const retiredPackageIds = [...new Set(
461
+ declarations.map((entry) => entry.packageId).filter((packageId) => Object.hasOwn(RETIRED_PACKAGES, packageId))
462
+ )].sort();
463
+ const retiredImports = retiredPackageIds.length > 0 ? await findImports(root, retiredPackageIds) : new Map();
464
+ if (retiredImports.size > 0) {
465
+ throw new Error(`Cannot update while the application imports retired JSKIT packages:\n${[...retiredImports]
466
+ .map(([packageId, files]) => `- ${packageId} (in ${files.sort().join(", ")}): ${RETIRED_PACKAGES[packageId].packageId} ` +
467
+ `replaced it; import from ${RETIRED_PACKAGES[packageId].importFrom} instead.`)
468
+ .join("\n")}`);
469
+ }
417
470
  const unknownPackageIds = [...new Set(
418
471
  declarations
419
472
  .map((entry) => entry.packageId)
420
- .filter((packageId) => !normalizedCatalog.versions.has(packageId))
473
+ .filter((packageId) => !normalizedCatalog.versions.has(packageId) && !retiredPackageIds.includes(packageId))
421
474
  )].sort();
422
475
  if (unknownPackageIds.length > 0) {
423
476
  throw new Error(`Cannot update JSKIT packages missing from the coordinated catalog: ${unknownPackageIds.join(", ")}.`);
@@ -449,6 +502,11 @@ async function updateProject({
449
502
  if (!packageId.startsWith(JSKIT_SCOPE_PREFIX) || localPackageNames.has(packageId)) {
450
503
  continue;
451
504
  }
505
+ if (retiredPackageIds.includes(packageId)) {
506
+ delete next[field][packageId];
507
+ changed = true;
508
+ continue;
509
+ }
452
510
  const expectedVersion = normalizedCatalog.versions.get(packageId);
453
511
  if (next[field][packageId] !== expectedVersion) {
454
512
  next[field][packageId] = expectedVersion;
@@ -527,7 +585,11 @@ async function updateProject({
527
585
  changedFiles: Object.freeze(
528
586
  updates.map((update) => toPosixPath(path.relative(root, update.manifest.absolutePath)))
529
587
  ),
530
- catalogVersion
588
+ catalogVersion,
589
+ retiredPackages: Object.freeze(retiredPackageIds.map((packageId) => Object.freeze({
590
+ packageId,
591
+ replacedBy: RETIRED_PACKAGES[packageId].packageId
592
+ })))
531
593
  });
532
594
  }
533
595
 
@@ -249,6 +249,17 @@ async function validateMigrationDirectory(packageName, absoluteDirectory, relati
249
249
  return entries;
250
250
  }
251
251
 
252
+ // Each apiSummary surface names an entrypoint the package exports, or a group of them ("./server" for its "./server/..."
253
+ // subpaths), so the catalog never describes an import that fails.
254
+ function validateApiSummarySurfaces(packageJson) {
255
+ const exported = packageJson.exports && typeof packageJson.exports === "object" ? Object.keys(packageJson.exports) : [];
256
+ for (const { subpath } of packageJson.jskit?.metadata?.apiSummary?.surfaces || []) {
257
+ if (!exported.some((entry) => entry === subpath || entry.startsWith(`${subpath}/`))) {
258
+ throw new Error(`${packageJson.name}#jskit.metadata.apiSummary describes ${subpath}, which the package does not export.`);
259
+ }
260
+ }
261
+ }
262
+
252
263
  async function validatePackage(packageRecord, localVersions, migrationOwners) {
253
264
  const { packageJson, packageRoot, manifestPath } = packageRecord;
254
265
  const packageId = String(packageJson.name || "").trim();
@@ -282,6 +293,7 @@ async function validatePackage(packageRecord, localVersions, migrationOwners) {
282
293
  throw new Error(`${packageId} cannot both provide and require the same capability.`);
283
294
  }
284
295
 
296
+ validateApiSummarySurfaces(packageJson);
285
297
  const serverProviderCount = await validateProviderList(packageRecord, "server");
286
298
  await validateProviderList(packageRecord, "client");
287
299
  await validateMigrations(packageRecord, migrationOwners);
@@ -337,12 +349,22 @@ function validateCapabilityClosure(packages, { builtinCapabilities = BUILTIN_CAP
337
349
  }
338
350
  }
339
351
 
352
+ // The application's one table runtime: a package that takes it as a dependency can install a second copy of it beside
353
+ // the application's, with its own tables, transactions and error classes. Every package takes it as a peer.
354
+ const TABLE_RUNTIME_PACKAGE_IDS = Object.freeze(["@jskit-ai/database-runtime", "@jskit-ai/resource-runtime"]);
355
+
340
356
  function validateSingletonPeerDependencies(packages) {
341
357
  const singletonPackageIds = new Set(collectSingletonPackageIds(packages.map(({ packageJson }) => packageJson)));
342
358
 
343
359
  for (const { packageJson } of packages) {
344
360
  for (const section of ["dependencies", "optionalDependencies"]) {
345
361
  for (const dependencyId of Object.keys(packageJson[section] || {})) {
362
+ if (TABLE_RUNTIME_PACKAGE_IDS.includes(dependencyId)) {
363
+ throw new Error(
364
+ `${packageJson.name}#${section}.${dependencyId} must be declared in peerDependencies because ` +
365
+ `${dependencyId} is the application's one table runtime.`
366
+ );
367
+ }
346
368
  if (!singletonPackageIds.has(dependencyId)) {
347
369
  continue;
348
370
  }
@@ -400,6 +422,7 @@ if (process.argv[1] && path.resolve(process.argv[1]) === SCRIPT_PATH) {
400
422
 
401
423
  export {
402
424
  main,
425
+ validateApiSummarySurfaces,
403
426
  validateCapabilityClosure,
404
427
  validateMigrations,
405
428
  validateProviderExport,