@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/catalog/packages.json +272 -265
- package/package.json +1 -1
- package/scripts/data-access-files.mjs +140 -0
- package/scripts/data-access.mjs +18 -112
- package/scripts/jskit.mjs +12 -12
- package/scripts/material3.mjs +41 -37
- package/scripts/ui-components.mjs +159 -0
- package/scripts/verify-packages.mjs +18 -0
package/package.json
CHANGED
|
@@ -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 };
|
package/scripts/data-access.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { createRequire } from "node:module";
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
3
2
|
import path from "node:path";
|
|
4
|
-
import {
|
|
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.
|
|
19
|
-
//
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
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
|
|
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/*)
|
|
42
|
-
" navigation, a hand-built in-app path, a back control other than
|
|
43
|
-
" page without a declared parent
|
|
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
|
|
55
|
-
"
|
|
56
|
-
" changed files).
|
|
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
|
|
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
|
-
|
|
282
|
+
formatMaterial3Report(ui),
|
|
283
283
|
formatTestRunsReport(tests)
|
|
284
284
|
];
|
|
285
285
|
if (!result.ok || !ui.ok || !tests.ok || !data.ok) {
|
package/scripts/material3.mjs
CHANGED
|
@@ -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
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
// folder or a file: the only exception is one line's
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
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[]
|
|
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 = []
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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}.${
|
|
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
|
|
422
|
+
export { RULES as MATERIAL3_RULES, checkMaterial3, formatMaterial3Report };
|