@jskit-ai/jskit-catalog 0.2.42 → 0.2.44

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.42",
3
+ "version": "0.2.44",
4
4
  "description": "Published metadata catalog for JSKIT package metadata.",
5
5
  "type": "module",
6
6
  "files": [
@@ -0,0 +1,140 @@
1
+ import { readdir, 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
+
7
+ // The files `jskit check`'s data part (data-access.mjs) reads: the app's own source, but what its ESLint config ignores
8
+ // outside server source (SERVER_FILE), which no lint reads either; and the source of its installed @jskit-ai packages.
9
+ // With them, each app file's ESLint config, which data/lint-config judges, and the paths it skipped, which it lists.
10
+
11
+ // 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
12
+ // test/ folder deeper in server code is server code (as in @jskit-ai/config-eslint's dataAccess.js).
13
+ const ROOT = String.raw`(?:(?:packages|tooling)/[^/]+/)?`;
14
+ // Server source, which the app's ESLint config may not ignore, as @jskit-ai/config-eslint's base.js and dataAccess.js
15
+ // lint it: at a root (ROOT), its files (a root server.js) and its src/ (an app's whole src/, src/jobs/ and its server
16
+ // code with it; a package's src/server and src/shared), server/, scripts/, bin/ and config/. Anything else the config
17
+ // ignores, a vendored copy of old code or generated output, is not data-access code.
18
+ const SERVER_FILE = new RegExp(`^${ROOT}(?:[^/]+$|(?:src|server|scripts|bin|config)/)`, "u");
19
+ const FRAMEWORK_DIRECTORY = path.join("node_modules", "@jskit-ai");
20
+ const SOURCE_FILE = /\.[cm]?js$/u;
21
+ const SKIPPED_FILE = /\.(?:test|spec|vitest)\.[cm]?js$/u;
22
+ // Code that defines no table resource or system scope an app runs: dependencies, anywhere; and, at a root (ROOT), build
23
+ // output, tests and fixtures, migrations and seeds, and the examples, templates and documentation JSKIT packages carry.
24
+ const SKIPPED_ROOT_DIRECTORIES = new Set([
25
+ "dist", "build", "coverage", "test", "tests", "__tests__", "e2e", "fixtures", "test-results", "migrations", "seed",
26
+ "seeds", "templates", "patterns", "examples", "docs", "site", "skills", "test-support", "src/test"
27
+ ]);
28
+ const PACKAGE_DIRECTORY = /^(?:packages|tooling)\/[^/]+\/(.+)$/u;
29
+ const skipsDirectory = (name) => path.posix.basename(name) === "node_modules" ||
30
+ SKIPPED_ROOT_DIRECTORIES.has(PACKAGE_DIRECTORY.exec(name)?.[1] ?? name);
31
+
32
+ async function walk(root, directory, files) {
33
+ let entries;
34
+ try {
35
+ entries = await readdir(path.join(root, directory), { withFileTypes: true });
36
+ } catch (error) {
37
+ if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return;
38
+ throw error;
39
+ }
40
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
41
+ const relative = directory ? path.join(directory, entry.name) : entry.name;
42
+ if (entry.isDirectory()) {
43
+ if (!entry.name.startsWith(".") && !skipsDirectory(toPosixPath(relative))) await walk(root, relative, files);
44
+ } else if (entry.isFile() && SOURCE_FILE.test(entry.name) && !SKIPPED_FILE.test(entry.name)) {
45
+ files.push(relative);
46
+ }
47
+ }
48
+ }
49
+
50
+ // The installed @jskit-ai packages outside the app's own tree (a workspace's linked packages are read as app source),
51
+ // each as { directory, real }: where the app sees it and where it is.
52
+ async function frameworkPackages(root) {
53
+ let entries;
54
+ try {
55
+ entries = await readdir(path.join(root, FRAMEWORK_DIRECTORY), { withFileTypes: true });
56
+ } catch (error) {
57
+ if (error?.code === "ENOENT") return [];
58
+ throw error;
59
+ }
60
+ const packages = [];
61
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
62
+ const directory = path.join(FRAMEWORK_DIRECTORY, entry.name);
63
+ const real = await realpath(path.join(root, directory)).catch(() => "");
64
+ const inside = path.relative(root, real);
65
+ const appSource = real && !inside.startsWith("..") && !path.isAbsolute(inside) && !inside.split(path.sep).includes("node_modules");
66
+ if (real && !appSource) packages.push({ directory, real });
67
+ }
68
+ return packages;
69
+ }
70
+
71
+ // The app's ESLint, from the app or else beside this check, set to find each file's config as `eslint` does; null when
72
+ // neither has one, or the app has no config: then the lint itself cannot run, which fails on its own.
73
+ async function appEslint(root) {
74
+ for (const base of [path.join(root, "package.json"), fileURLToPath(import.meta.url)]) {
75
+ let resolved;
76
+ try {
77
+ resolved = createRequire(base).resolve("eslint");
78
+ } catch {
79
+ continue;
80
+ }
81
+ const api = await import(pathToFileURL(resolved).href);
82
+ const eslint = new (api.ESLint || api.default.ESLint)({ cwd: root });
83
+ return await eslint.findConfigFile(path.join(root, "package.json")) ? eslint : null;
84
+ }
85
+ return null;
86
+ }
87
+
88
+ // The ESLint config of each app file that one governs, read as `eslint` reads it: each { file, governed, config },
89
+ // `governed` the file's path from that config's directory and `config` undefined when the config ignores the file; and the
90
+ // first error reading one, after which no more are read. None when the app has no ESLint config (appEslint).
91
+ async function lintConfigs(root, files) {
92
+ const configs = [];
93
+ const eslint = await appEslint(root);
94
+ for (const file of eslint ? files : []) {
95
+ const absolute = path.join(root, file);
96
+ try {
97
+ const configFile = await eslint.findConfigFile(absolute);
98
+ if (!configFile) continue;
99
+ const governed = toPosixPath(path.relative(path.dirname(configFile), absolute));
100
+ configs.push({ file, governed, config: await eslint.calculateConfigForFile(absolute) });
101
+ } catch (error) {
102
+ return { configs, error: { file, message: String(error?.message || error).split("\n")[0] } };
103
+ }
104
+ }
105
+ return { configs };
106
+ }
107
+
108
+ // Code the app's ESLint config ignores that is not server source: no lint reads it, so neither does this check.
109
+ const lintIgnores = ({ governed, config }) => !config && !SERVER_FILE.test(governed);
110
+
111
+ // The fewest paths that name the app files skipped: for each, the highest directory holding no file that is read, written
112
+ // with a trailing "/", or else the file itself.
113
+ function skippedPaths(files, skipped) {
114
+ const directories = (file) => file.split("/").slice(0, -1).map((_, index, parts) => parts.slice(0, index + 1).join("/"));
115
+ const read = new Set(files.filter((file) => !skipped.has(file)).flatMap(directories));
116
+ return [...new Set([...skipped].map((file) => {
117
+ const directory = directories(file).find((name) => !read.has(name));
118
+ return directory ? `${directory}/` : file;
119
+ }))].sort();
120
+ }
121
+
122
+ // The app's source files but those its ESLint config ignores outside server source (lintIgnores), and those of its
123
+ // installed @jskit-ai packages (framework: true), each { file, absolute, framework }; with each app file's ESLint config
124
+ // (lintConfigs) and the paths of the files skipped (skippedPaths).
125
+ async function sourceFiles(root) {
126
+ const appFiles = [];
127
+ await walk(root, "", appFiles);
128
+ const names = appFiles.map(toPosixPath);
129
+ const lint = await lintConfigs(root, names);
130
+ const skipped = new Set(lint.configs.filter(lintIgnores).map(({ file }) => file));
131
+ const sources = names.filter((file) => !skipped.has(file)).map((file) => ({ file, absolute: path.join(root, file), framework: false }));
132
+ for (const { directory, real } of await frameworkPackages(root)) {
133
+ const files = [];
134
+ await walk(real, "", files);
135
+ sources.push(...files.map((file) => ({ file: path.join(directory, file), absolute: path.join(real, file), framework: true })));
136
+ }
137
+ return { sources, lint, skipped: skippedPaths(names, skipped) };
138
+ }
139
+
140
+ export { ROOT, lintIgnores, sourceFiles };
@@ -1,7 +1,6 @@
1
- import { readdir, readFile, realpath } from "node:fs/promises";
2
- import { createRequire } from "node:module";
1
+ import { readFile } from "node:fs/promises";
3
2
  import path from "node:path";
4
- import { fileURLToPath, pathToFileURL } from "node:url";
3
+ import { ROOT, lintIgnores, sourceFiles } from "./data-access-files.mjs";
5
4
  import { toPosixPath } from "./files.mjs";
6
5
  import { blankJsComments, lineIndex } from "./material3-source.mjs";
7
6
 
@@ -15,8 +14,9 @@ import { blankJsComments, lineIndex } from "./material3-source.mjs";
15
14
  // that requires a capability handing out power past the owners (CROSS_OWNER_CAPABILITIES) fails until it is reviewed
16
15
  // too; an eslint-disable or inline eslint config that turns off a data-access rule, or every rule, fails in any file
17
16
  // 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.
17
+ // error, or ignores though it is server code. Whatever else that config ignores, no lint reads, so neither does this
18
+ // check: it lists what it skipped. As information, it lists the fields of each app table resource that its CRUD contracts
19
+ // never name, which no API shows or accepts.
20
20
 
21
21
  const GUIDE = "https://mobily-enterprises.github.io/jskit-ai/guide/framework/data-access";
22
22
  const SECTIONS = Object.freeze({
@@ -70,9 +70,6 @@ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
70
70
  // A capability id written as a string, compared as the kernel resolves it: trimmed and in any case.
71
71
  const CAPABILITY_NAMES = Object.keys(CROSS_OWNER_CAPABILITIES).map(escapeRegExp).join("|");
72
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
73
  // Client code, which the data-access rules leave alone: a package's src/client. An app's own src/ is linted whole, since
77
74
  // server code sits there too.
78
75
  const CLIENT_FILE = new RegExp(`^${ROOT}src/client/`, "u");
@@ -81,20 +78,6 @@ const CLIENT_FILE = new RegExp(`^${ROOT}src/client/`, "u");
81
78
  // tests, migrations, seeds and build output at a root, seed scripts, and test files.
82
79
  const LINT_EXEMPT = new RegExp(`^${ROOT}(?:(?:src/client|src/test|test|tests|__tests__|e2e|test-support|migrations|seeds?|` +
83
80
  "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
81
  const MARKERS = new RegExp(`defineTableResource|defineCrudContract|systemScope|systemContext|readable-check: allow|eslint|${CAPABILITY_NAMES}`, "iu");
99
82
  const TABLE_RESOURCE_CALL = /\bdefineTableResource\s*\(/gu;
100
83
  const TABLE_RESOURCE_MARKER = /\bdefineTableResource\s*\(/u;
@@ -441,43 +424,14 @@ function readRuntimeCapabilities(file, { code, masked, position }) {
441
424
  // Whether a rule's setting in a computed config is "error", which ESLint gives as 2 or [2, ...options].
442
425
  const isError = (setting) => [2, "error"].includes(Array.isArray(setting) ? setting[0] : setting);
443
426
 
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
427
  // Fails each app file its ESLint config lints without every data-access rule as an error, and each server file it
462
428
  // ignores, unless the baseline's infrastructure lists { file, rule } for each rule that is not on. Each file is judged by
463
429
  // the config that governs it, as `eslint` does, and what the rules leave alone (LINT_EXEMPT) is told from that config's
464
430
  // 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;
431
+ function checkLintConfig({ configs, error }, baseline, report) {
432
+ if (error) report({ file: error.file, line: 1 }, "data/lint-config", `ESLint cannot read the app's config for this file: ${error.message}`);
433
+ for (const { file, governed, config } of configs) {
434
+ if (LINT_EXEMPT.test(governed) || lintIgnores({ governed, config })) continue;
481
435
  const off = DATA_ACCESS_RULES.filter((rule) => !isError(config?.rules?.[`jskit/${rule}`]) &&
482
436
  !baseline.infrastructure.some((item) => item?.file === file && item?.rule === rule));
483
437
  if (off.length < 1) continue;
@@ -541,45 +495,6 @@ function checkBaseline(baseline, excuses, scopes, report) {
541
495
  }
542
496
  }
543
497
 
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
498
  // Every table resource of a table, and of a name, that another one shares: each one is reported, naming the others.
584
499
  function checkTables(resources, report) {
585
500
  const where = (resource) => `${resource.file}:${resource.line}`;
@@ -622,19 +537,6 @@ function checkSystemScopes(scopes, report) {
622
537
  }
623
538
  }
624
539
 
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
540
  /**
639
541
  * The table resources the app and its installed @jskit-ai packages define, read from source as checkDataAccess reads
640
542
  * them: each { file, absolute, framework, name, tableName, foreignKeys }, `foreignKeys` true when the definition
@@ -643,7 +545,7 @@ async function sourceFiles(root) {
643
545
  */
644
546
  async function readTableResourceSources({ projectRoot = process.cwd() } = {}) {
645
547
  const resources = [];
646
- for (const { file, absolute, framework } of await sourceFiles(path.resolve(projectRoot))) {
548
+ for (const { file, absolute, framework } of (await sourceFiles(path.resolve(projectRoot))).sources) {
647
549
  const source = await readFile(absolute, "utf8");
648
550
  if (!TABLE_RESOURCE_MARKER.test(source)) continue;
649
551
  const read = readSourceFile(source);
@@ -660,7 +562,7 @@ async function readTableResourceSources({ projectRoot = process.cwd() } = {}) {
660
562
  */
661
563
  async function checkDataAccess({ projectRoot = process.cwd() } = {}) {
662
564
  const root = path.resolve(projectRoot);
663
- const sources = await sourceFiles(root);
565
+ const { sources, lint, skipped } = await sourceFiles(root);
664
566
  const resources = [];
665
567
  const contracts = [];
666
568
  const scopes = [];
@@ -692,8 +594,7 @@ async function checkDataAccess({ projectRoot = process.cwd() } = {}) {
692
594
  const baseline = await readBaseline(root, report);
693
595
  checkBaseline(baseline, excuses, scopes, report);
694
596
  checkCapabilities(baseline, capabilities, report);
695
- const appFiles = sources.filter((source) => !source.framework).map((source) => toPosixPath(source.file));
696
- await checkLintConfig(root, appFiles, baseline, report);
597
+ checkLintConfig(lint, baseline, report);
697
598
  findings.sort((left, right) => left.file.localeCompare(right.file) || left.line - right.line);
698
599
  return {
699
600
  ok: findings.length < 1,
@@ -702,7 +603,8 @@ async function checkDataAccess({ projectRoot = process.cwd() } = {}) {
702
603
  resources: resources.length,
703
604
  systemScopes: scopes.filter((scope) => scope.reason !== null),
704
605
  excuses,
705
- unnamedFields: unnamedFields(resources, contracts)
606
+ unnamedFields: unnamedFields(resources, contracts),
607
+ skipped
706
608
  };
707
609
  }
708
610
 
@@ -728,6 +630,10 @@ function formatDataAccessReport(result) {
728
630
  `name one in a contract's fields to offer it (data/unnamed-fields; ${GUIDE}#${SECTIONS["data/unnamed-fields"]}):`);
729
631
  for (const { file, line, name, fields } of result.unnamedFields) lines.push(` ${file}:${line} ${name}: ${fields.join(", ")}`);
730
632
  }
633
+ if (result.skipped.length > 0) {
634
+ lines.push(`Skipped, as the app's ESLint config ignores them and they hold no server code (${result.skipped.length}):`);
635
+ for (const skipped of result.skipped) lines.push(` ${skipped}`);
636
+ }
731
637
  const listed = `${result.systemScopes.length} system scope(s) and ${result.excuses.length} infrastructure exception(s) listed for review`;
732
638
  lines.push(result.ok
733
639
  ? `JSKIT data access: ${result.resources} table resource(s), each table and name defined once; ${listed}.`
package/scripts/jskit.mjs CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  formatExportSummary
13
13
  } from "./i18n.mjs";
14
14
  import { checkDataAccess, formatDataAccessReport } from "./data-access.mjs";
15
- import { checkMaterial3, formatMaterial3Report, formatNavigationReport } from "./material3.mjs";
15
+ import { checkMaterial3, formatMaterial3Report } from "./material3.mjs";
16
16
  import { checkProject, updateProject } from "./project-packages.mjs";
17
17
  import { runTests } from "./test-command.mjs";
18
18
  import { checkTestRuns, formatTestRunsReport } from "./test-runs.mjs";
@@ -21,9 +21,6 @@ const SCRIPT_PATH = fileURLToPath(import.meta.url);
21
21
  const PACKAGE_ROOT = path.resolve(path.dirname(SCRIPT_PATH), "..");
22
22
  const CATALOG_PATH = path.join(PACKAGE_ROOT, "catalog", "packages.json");
23
23
  const I18N_SUBCOMMANDS = new Set(["export", "check"]);
24
- // #241: plain `jskit check` fails on every nav/* rule. The JSKIT release that ships once every app has
25
- // been converted sets this to true, so plain `jskit check` (and every app's verify) fails on every m3/* finding too.
26
- const MATERIAL3_GATE = false;
27
24
 
28
25
  function usage() {
29
26
  return [
@@ -38,9 +35,12 @@ function usage() {
38
35
  "Commands:",
39
36
  " update Align root and workspace @jskit-ai/* declarations to this published catalog, then run npm install.",
40
37
  " check Fail when manifests or package-lock.json contain stale, mixed, or private singleton JSKIT stacks,",
41
- " or when application source breaks a JSKIT navigation rule (nav/*): history or window.location",
42
- " navigation, a hand-built in-app path, a back control other than the shell's Up arrow, an inner",
43
- " page without a declared parent, or a main-navigation page that declares one. It also fails when",
38
+ " or when application source breaks a rule of the JSKIT Material 3 guide: a navigation rule (nav/*),",
39
+ " such as history or window.location navigation, a hand-built in-app path, a back control other than",
40
+ " the shell's Up arrow, an inner page without a declared parent or a main-navigation page that",
41
+ " declares one, a visual rule (m3/*), such as a raw colour, an MDI icon, an outlined card or a",
42
+ " fixed button height, or a rule for using components (ui/*), such as a prop a JSKIT component",
43
+ " no longer has. It also fails when",
44
44
  " a package script runs tests outside the machine's test budget (test/*): node --test instead of",
45
45
  " jskit test, a fixed test concurrency or worker count, or a Playwright or Vitest config whose",
46
46
  " workers do not come from testConcurrency() of @jskit-ai/jskit-catalog/test-budget. A Playwright",
@@ -51,9 +51,9 @@ function usage() {
51
51
  " scope (a feature's systemScope, a systemContext() call) gives no literal reason (data/system-scope),",
52
52
  " and it lists every system scope and every file the infrastructure exception lifts past the",
53
53
  " data-access lint rules for review.",
54
- " check m3 List application UI source that departs from the JSKIT Material 3 guide, each finding with its",
55
- " guide section: the whole app, or only the given files and directories (a section, or a task's",
56
- " changed files). Its visual rules (m3/*) are a report; its nav/* rules are plain jskit check's.",
54
+ " check m3 Check application UI source against the JSKIT Material 3 guide alone, with each finding's guide",
55
+ " section and every exception: the whole app, or only the given files and directories (a section,",
56
+ " or a task's changed files). Plain jskit check applies the same rules to the whole app.",
57
57
  " i18n export Write the English messages of the installed JSKIT packages to src/locales/<locale>/jskit.json",
58
58
  " for translators, keeping existing translations and extra keys.",
59
59
  " i18n check Compare src/locales/<locale>/jskit.json with the installed packages' English; fail on missing keys,",
@@ -269,7 +269,7 @@ async function main(argv = process.argv.slice(2)) {
269
269
  checkInstalled: true,
270
270
  catalog
271
271
  });
272
- const ui = await checkMaterial3({ projectRoot: options.projectRoot, navigationOnly: !MATERIAL3_GATE });
272
+ const ui = await checkMaterial3({ projectRoot: options.projectRoot });
273
273
  const tests = await checkTestRuns({ projectRoot: options.projectRoot, manifests: result.manifests });
274
274
  const data = await checkDataAccess({ projectRoot: options.projectRoot });
275
275
  // The data rules first: a graph that fails on many pin versions would otherwise bury them.
@@ -279,7 +279,7 @@ async function main(argv = process.argv.slice(2)) {
279
279
  ? `JSKIT project graph is coordinated across ${result.manifests.length} manifest(s) and ` +
280
280
  `${result.installations.length} resolved package installation(s).`
281
281
  : `JSKIT project graph is inconsistent:\n- ${result.issues.join("\n- ")}`,
282
- MATERIAL3_GATE ? formatMaterial3Report(ui) : formatNavigationReport(ui),
282
+ formatMaterial3Report(ui),
283
283
  formatTestRunsReport(tests)
284
284
  ];
285
285
  if (!result.ok || !ui.ok || !tests.ok || !data.ok) {
@@ -24,12 +24,14 @@ import {
24
24
  readTags,
25
25
  splitVue
26
26
  } from "./material3-source.mjs";
27
+ import { checkRemovedProps, checkVuetifyByNameAttribute, checkVuetifyByNameCode, readRemovedProps, readVuetifyComponents } from "./ui-components.mjs";
27
28
 
28
- // `jskit check m3 [path ...]`: the conformance check of the JSKIT Material 3 guide. It reads application source,
29
- // never runs it. With no paths it checks the whole app; paths narrow it to a section or a task's changed files. The
30
- // navigation rules (nav/*) are also part of plain `jskit check`, which fails on them for every JSKIT app (#241); the
31
- // visual rules (m3/*) join them there once every app has been converted. Nothing turns the check off for an app, a
32
- // folder or a file: the only exception is one line's `m3-check: allow <m3 rule> - <reason>`.
29
+ // The conformance check of the JSKIT Material 3 guide, its navigation rules (nav/*), its visual rules (m3/*) and its
30
+ // rules for using components (ui/*, ui-components.mjs). It
31
+ // reads application source, never runs it. Plain `jskit check` runs it over the whole app and fails on any finding, in
32
+ // every JSKIT app's verify (#241); `jskit check m3 [path ...]` runs it alone, narrowed to a section or a task's changed
33
+ // files. Nothing turns the check off for an app, a folder or a file: the only exception is one line's
34
+ // `m3-check: allow <m3 rule> - <reason>`.
33
35
 
34
36
  const GUIDE = "https://mobily-enterprises.github.io/jskit-ai/guide/framework/material-3";
35
37
  const RULES = Object.freeze({
@@ -40,6 +42,8 @@ const RULES = Object.freeze({
40
42
  "m3/outlined-card": "colour",
41
43
  "m3/mdi-icon": "foundation",
42
44
  "m3/fixed-button-height": "exact-sizes",
45
+ "ui/removed-prop": "foundation",
46
+ "ui/vuetify-by-name": "foundation",
43
47
  "m3/allow-reason": "exceptions",
44
48
  "m3/allow-rule": "exceptions",
45
49
  "nav/allow": "exceptions"
@@ -74,6 +78,13 @@ const CHANNEL_COLOUR = String.raw`\b(?:rgba?|hsla?)\(\s*[\d.][^()]*\)`;
74
78
  const channelColour = () => new RegExp(CHANNEL_COLOUR, "giu");
75
79
  const exactChannelColour = new RegExp(`^${CHANNEL_COLOUR}$`, "iu");
76
80
  const CHANNEL_FIX = "Use rgb(var(--v-theme-<role>)), with an alpha for a shadow or scrim: rgba(var(--v-theme-shadow), 0.2).";
81
+ // An attribute that takes a colour as a plain value: a Vuetify colour prop (color, bg-color, baseColor) or SVG paint.
82
+ // Any other attribute's "#add" is a fragment or text.
83
+ const COLOUR_ATTRIBUTE = /(?:colou?r|^fill|^stroke)$/iu;
84
+ // A url(...) in CSS, whose "#fade" names an SVG element, not a colour.
85
+ const CSS_URL = /url\([^)]*\)/giu;
86
+ // A border attribute that draws none: a bound false, 0, null or undefined, or "0" (Vuetify's border-0).
87
+ const NO_BORDER = /^(?:false|0|null|undefined)$/u;
77
88
  const quotedHex = () => new RegExp(`(["'\`])(${HEX_COLOUR})\\1`, "gu");
78
89
  const MDI_STRING = /\bmdi-[a-z][a-z0-9-]*/gu;
79
90
  const MDI_IMPORT = /(?:from\s*|import\s*|require\(\s*)["'](@mdi\/(?:js|font)[^"']*)["']/gu;
@@ -154,7 +165,7 @@ async function collectFiles(projectRoot, paths) {
154
165
 
155
166
  // One line's `m3-check: allow <m3 rule> - <reason>` excuses that rule on its own line and the next. Any other
156
167
  // m3-check comment is itself a finding: one naming a nav/* rule (nothing excuses those), an unknown rule, or no reason.
157
- function createReporter(file, source, { navigationOnly = false } = {}) {
168
+ function createReporter(file, source) {
158
169
  const position = lineIndex(source);
159
170
  const findings = [];
160
171
  const exceptions = [];
@@ -163,8 +174,6 @@ function createReporter(file, source, { navigationOnly = false } = {}) {
163
174
  const at = { file, line, column: 1 };
164
175
  if (isNavigationRule(rule)) {
165
176
  findings.push({ rule: "nav/allow", ...at, message: `Nothing excuses a navigation rule (${rule}). Delete the comment and fix the line.` });
166
- } else if (navigationOnly) {
167
- continue;
168
177
  } else if (!EXCUSABLE_RULES.has(rule)) {
169
178
  const what = rule ? `${rule} is not a Material 3 rule` : "The comment is not \"m3-check: allow <rule> - <reason>\"";
170
179
  findings.push({ rule: "m3/allow-rule", ...at, message: `${what}, so it excuses nothing.` });
@@ -176,7 +185,6 @@ function createReporter(file, source, { navigationOnly = false } = {}) {
176
185
  }
177
186
  }
178
187
  function report(rule, offset, message) {
179
- if (navigationOnly && !isNavigationRule(rule)) return;
180
188
  const at = position(offset);
181
189
  if (excused.get(at.line)?.includes(rule)) return;
182
190
  findings.push({ rule, file, line: at.line, column: at.column, message });
@@ -185,7 +193,8 @@ function createReporter(file, source, { navigationOnly = false } = {}) {
185
193
  }
186
194
 
187
195
  // Hex colours and navigation in any JavaScript: a script block, a module, or a bound or event attribute's expression.
188
- function checkCode(code, offset, { report, themed }) {
196
+ function checkCode(code, offset, context) {
197
+ const { report, themed } = context;
189
198
  if (!themed) {
190
199
  for (const match of code.matchAll(quotedHex())) {
191
200
  report("m3/raw-hex", offset + match.index, `Raw colour ${match[2]}. Use a theme colour role. ${CONTENT_COLOUR}`);
@@ -195,6 +204,7 @@ function checkCode(code, offset, { report, themed }) {
195
204
  }
196
205
  }
197
206
  checkNavigationCode(code, offset, report);
207
+ checkVuetifyByNameCode(code, offset, context);
198
208
  }
199
209
 
200
210
  function checkScript(code, offset, context) {
@@ -224,14 +234,16 @@ function checkCss(css, offset, context) {
224
234
  function checkDeclaration(declaration, context) {
225
235
  const { report, themed } = context;
226
236
  if (!themed) {
227
- for (const match of declaration.value.matchAll(hexPattern())) {
237
+ const value = declaration.value.replace(CSS_URL, (url) => " ".repeat(url.length));
238
+ for (const match of value.matchAll(hexPattern())) {
228
239
  report("m3/raw-hex", declaration.valueOffset + match.index, `Raw colour ${match[0]}. Use rgb(var(--v-theme-<role>)). ${CONTENT_COLOUR}`);
229
240
  }
230
- for (const match of declaration.value.matchAll(channelColour())) {
241
+ for (const match of value.matchAll(channelColour())) {
231
242
  report("m3/raw-hex", declaration.valueOffset + match.index, `Raw colour ${match[0]}. ${CHANNEL_FIX} ${CONTENT_COLOUR}`);
232
243
  }
233
244
  }
234
- const buttonRule = declaration.selectors.some((selector) => /\.v-btn\b/u.test(selector));
245
+ // .v-btn and its modifiers (.v-btn--size-large), not .v-btn-group or .v-btn-toggle.
246
+ const buttonRule = declaration.selectors.some((selector) => /\.v-btn(?:--[\w-]+)?(?![\w-])/u.test(selector));
235
247
  if (buttonRule && ["height", "min-height", "--v-btn-height"].includes(declaration.property)) {
236
248
  report("m3/fixed-button-height", declaration.offset, `A fixed ${declaration.property} on buttons. JSKIT ships the 40dp button with its 48dp touch target; remove it. ${SMALLER_BUTTON}`);
237
249
  }
@@ -249,6 +261,7 @@ function checkTemplate(template, context) {
249
261
  }
250
262
  // The branches each h1 renders under: h1s on different branches of one v-if chain never show together.
251
263
  const branches = readConditionalBranches(template);
264
+ checkRemovedProps(tags, context.script, context.removedProps, report);
252
265
  const headings = [];
253
266
  for (const tag of tags) {
254
267
  for (const entry of tag.attributes) {
@@ -274,10 +287,12 @@ function checkAttribute(tag, entry, context) {
274
287
  for (const declaration of readInlineDeclarations(entry.value, entry.valueOffset)) {
275
288
  checkDeclaration({ ...declaration, selectors: tag.name === "v-btn" ? [".v-btn"] : [] }, context);
276
289
  }
277
- } else if (!themed && (exactHex.test(entry.value.trim()) || exactChannelColour.test(entry.value.trim()))) {
290
+ } else if (!themed && COLOUR_ATTRIBUTE.test(entry.name) &&
291
+ (exactHex.test(entry.value.trim()) || exactChannelColour.test(entry.value.trim()))) {
278
292
  report("m3/raw-hex", entry.valueOffset, `Raw colour ${entry.value.trim()}. Use a theme colour role such as color="primary". ${CONTENT_COLOUR}`);
279
293
  }
280
294
  checkLinkAttribute(tag, entry, context.report);
295
+ checkVuetifyByNameAttribute(tag, entry, context);
281
296
  }
282
297
 
283
298
  function checkSurface(tag, report) {
@@ -286,7 +301,7 @@ function checkSurface(tag, report) {
286
301
  report("m3/outlined-card", variant.offset, `${tag.name} with variant="outlined". Material 3 separates surfaces by tone: use the flat default or a surface-container colour.`);
287
302
  }
288
303
  const border = attribute(tag, "border");
289
- if (border) {
304
+ if (border && !(border.bound ? NO_BORDER.test(border.value.trim()) : border.value.trim() === "0")) {
290
305
  report("m3/outlined-card", border.offset, `${tag.name} with a border. Material 3 separates surfaces by tone: use the flat default or a surface-container colour.`);
291
306
  }
292
307
  }
@@ -299,7 +314,7 @@ function checkButtonSize(tag, report) {
299
314
  }
300
315
 
301
316
  function checkFile(file, source, context) {
302
- const { report, findings, exceptions } = createReporter(file, source, context);
317
+ const { report, findings, exceptions } = createReporter(file, source);
303
318
  const fileContext = { ...context, report, file, themed: file.split("/").slice(0, -1).includes(THEME_DIRECTORY) };
304
319
  const extension = path.extname(file);
305
320
  if (extension === ".vue") {
@@ -354,30 +369,30 @@ async function checkDarkScheme(projectRoot, checkedFiles, wholeApp) {
354
369
 
355
370
  /**
356
371
  * Checks application source against the Material 3 guide.
357
- * @param {{ projectRoot?: string, paths?: string[], navigationOnly?: boolean }} options paths are files or directories
358
- * relative to the root; navigationOnly keeps only the nav/* rules, as plain jskit check does for the whole app
372
+ * @param {{ projectRoot?: string, paths?: string[] }} options paths are files or directories relative to the root
359
373
  */
360
- async function checkMaterial3({ projectRoot = process.cwd(), paths = [], navigationOnly = false } = {}) {
374
+ async function checkMaterial3({ projectRoot = process.cwd(), paths = [] } = {}) {
361
375
  const root = path.resolve(projectRoot);
362
376
  const { files, missing } = await collectFiles(root, paths);
363
377
  const appFiles = paths.length === 0 ? files : (await collectFiles(root, [])).files;
364
- const context = { navigationOnly };
365
378
  // Only a page under the shell's top app bar has its title there; one without a bar has its own h1.
366
379
  const titledFiles = usesShell(await readProjectManifest(root))
367
380
  ? await readTitledFiles({ root, appFiles: [...new Set([...appFiles, ...files])] })
368
381
  : new Set();
382
+ const removedProps = await readRemovedProps(root);
383
+ const vuetifyComponents = await readVuetifyComponents(root);
369
384
  const findings = [];
370
385
  const exceptions = [];
371
386
  for (const file of files) {
372
- const checked = checkFile(file, await readFile(path.join(root, file), "utf8"), { ...context, titled: titledFiles.has(file) });
387
+ const context = { titled: titledFiles.has(file), removedProps, vuetifyComponents };
388
+ const checked = checkFile(file, await readFile(path.join(root, file), "utf8"), context);
373
389
  findings.push(...checked.findings);
374
390
  exceptions.push(...checked.exceptions);
375
391
  }
376
392
  const pages = await checkPageParents({ root, appFiles, pageFiles: files, report: (finding) => findings.push(finding) });
377
- if (!navigationOnly) findings.push(...await checkDarkScheme(root, files, paths.length === 0));
393
+ findings.push(...await checkDarkScheme(root, files, paths.length === 0));
378
394
  findings.sort((left, right) => left.file.localeCompare(right.file) || left.line - right.line || left.column - right.column);
379
- const errors = findings.filter((finding) => isNavigationRule(finding.rule)).length;
380
- return { projectRoot: root, wholeApp: paths.length === 0, navigationOnly, files, missing, pages, findings, exceptions, errors, ok: findings.length === 0 };
395
+ return { projectRoot: root, wholeApp: paths.length === 0, files, missing, pages, findings, exceptions, ok: findings.length === 0 };
381
396
  }
382
397
 
383
398
  function formatFindings(result) {
@@ -395,24 +410,13 @@ function formatMaterial3Report(result) {
395
410
  const scope = result.wholeApp ? "the whole app" : "the given paths";
396
411
  const fileCount = `${result.files.length} file(s)`;
397
412
  const withFindings = new Set(result.findings.map((finding) => finding.file)).size;
398
- const errors = result.errors > 0 ? ` ${result.errors} of them nav/* rules, which plain jskit check fails on.` : "";
399
413
  if (result.exceptions.length > 0) lines.push(`Exceptions (${result.exceptions.length}):`);
400
414
  for (const { file, line, rule, reason } of result.exceptions) lines.push(`${file}:${line} ${rule} ${reason}`);
401
415
  const exceptions = ` Exceptions: ${result.exceptions.length}.`;
402
416
  lines.push(result.ok
403
417
  ? `Material 3 check: no findings in ${fileCount} of ${scope}.${exceptions}`
404
- : `Material 3 check: ${result.findings.length} finding(s) in ${withFindings} of ${fileCount} of ${scope}.${errors}${exceptions}`);
405
- return lines.join("\n");
406
- }
407
-
408
- // The navigation part of plain jskit check: errors, with no exemptions.
409
- function formatNavigationReport(result) {
410
- const lines = result.ok ? [] : formatFindings(result);
411
- const pages = `${result.pages.length} shell page(s)`;
412
- lines.push(result.ok
413
- ? `JSKIT navigation rules pass in ${result.files.length} file(s) and ${pages}.`
414
- : `JSKIT navigation rules: ${result.findings.length} error(s) in ${result.files.length} file(s) and ${pages}. Fix each one; no comment excuses a navigation rule.`);
418
+ : `Material 3 check: ${result.findings.length} finding(s) in ${withFindings} of ${fileCount} of ${scope}.${exceptions}`);
415
419
  return lines.join("\n");
416
420
  }
417
421
 
418
- export { RULES as MATERIAL3_RULES, checkMaterial3, formatMaterial3Report, formatNavigationReport };
422
+ export { RULES as MATERIAL3_RULES, checkMaterial3, formatMaterial3Report };