@jskit-ai/jskit-catalog 0.2.31 → 0.2.32

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.31",
3
+ "version": "0.2.32",
4
4
  "description": "Published metadata catalog for JSKIT package metadata.",
5
5
  "type": "module",
6
6
  "files": [
@@ -14,13 +14,14 @@
14
14
  ".": "./catalog/packages.json",
15
15
  "./packages": "./catalog/packages.json",
16
16
  "./project-packages": "./scripts/project-packages.mjs",
17
+ "./test-budget": "./scripts/test-budget.mjs",
17
18
  "./package.json": "./package.json"
18
19
  },
19
20
  "scripts": {
20
21
  "catalog:build": "node ./scripts/build-catalog.mjs",
21
22
  "jskit:check": "node ./scripts/jskit.mjs check",
22
23
  "verify:packages": "node ./scripts/verify-packages.mjs",
23
- "test": "node --test --test-concurrency=1"
24
+ "test": "jskit test"
24
25
  },
25
26
  "engines": {
26
27
  "node": "^22.13.0 || ^24.0.0 || ^26.0.0"
package/scripts/jskit.mjs CHANGED
@@ -13,6 +13,8 @@ import {
13
13
  } from "./i18n.mjs";
14
14
  import { checkMaterial3, formatMaterial3Report, formatNavigationReport } from "./material3.mjs";
15
15
  import { checkProject, updateProject } from "./project-packages.mjs";
16
+ import { runTests } from "./test-command.mjs";
17
+ import { checkTestRuns, formatTestRunsReport } from "./test-runs.mjs";
16
18
 
17
19
  const SCRIPT_PATH = fileURLToPath(import.meta.url);
18
20
  const PACKAGE_ROOT = path.resolve(path.dirname(SCRIPT_PATH), "..");
@@ -30,13 +32,17 @@ function usage() {
30
32
  " jskit check m3 [<path> ...] [--root <path>]",
31
33
  " jskit i18n export <locale> [--root <path>]",
32
34
  " jskit i18n check <locale> [--root <path>]",
35
+ " jskit test [node options] [files or globs]",
33
36
  "",
34
37
  "Commands:",
35
38
  " update Align root and workspace @jskit-ai/* declarations to this published catalog, then run npm install.",
36
39
  " check Fail when manifests or package-lock.json contain stale, mixed, or private singleton JSKIT stacks,",
37
40
  " or when application source breaks a JSKIT navigation rule (nav/*): history or window.location",
38
41
  " navigation, a hand-built in-app path, a back control other than the shell's Up arrow, an inner",
39
- " page without a declared parent, or a main-navigation page that declares one.",
42
+ " page without a declared parent, or a main-navigation page that declares one. It also fails when",
43
+ " a package script runs tests outside the machine's test budget (test/*): node --test instead of",
44
+ " jskit test, a fixed test concurrency or worker count, or a Playwright or Vitest config whose",
45
+ " workers do not come from testConcurrency() of @jskit-ai/jskit-catalog/test-budget.",
40
46
  " check m3 List application UI source that departs from the JSKIT Material 3 guide, each finding with its",
41
47
  " guide section: the whole app, or only the given files and directories (a section, or a task's",
42
48
  " changed files). Its visual rules (m3/*) are a report; its nav/* rules are plain jskit check's.",
@@ -44,6 +50,10 @@ function usage() {
44
50
  " for translators, keeping existing translations and extra keys.",
45
51
  " i18n check Compare src/locales/<locale>/jskit.json with the installed packages' English; fail on missing keys,",
46
52
  " invalid messages or placeholder mismatches, and list extra keys and untranslated messages.",
53
+ " test Run node --test with these arguments, test files in parallel within the machine's test budget:",
54
+ " V64_TEST_CONCURRENCY when it is a positive whole number, otherwise half of the machine's CPUs",
55
+ " (at least 1). A file whose first line is `// jskit-test: alone - <reason>` runs after the others,",
56
+ " on its own.",
47
57
  "",
48
58
  "<locale> is a BCP 47 language tag, such as fr or pt-BR."
49
59
  ].join("\n");
@@ -55,6 +65,9 @@ function parseArgs(argv = []) {
55
65
  const command = commandArgument === "--help" || commandArgument === "-h"
56
66
  ? "help"
57
67
  : commandArgument;
68
+ if (command === "test") {
69
+ return { command, args };
70
+ }
58
71
  const options = {
59
72
  command,
60
73
  projectRoot: process.cwd(),
@@ -200,6 +213,10 @@ async function main(argv = process.argv.slice(2)) {
200
213
  await runMaterial3(options);
201
214
  return;
202
215
  }
216
+ if (options.command === "test") {
217
+ process.exitCode = await runTests(options.args);
218
+ return;
219
+ }
203
220
 
204
221
  const catalog = await readCatalog();
205
222
  if (options.command === "update") {
@@ -230,14 +247,16 @@ async function main(argv = process.argv.slice(2)) {
230
247
  catalog
231
248
  });
232
249
  const ui = await checkMaterial3({ projectRoot: options.projectRoot, navigationOnly: !MATERIAL3_GATE });
250
+ const tests = await checkTestRuns({ projectRoot: options.projectRoot, manifests: result.manifests });
233
251
  const reports = [
234
252
  result.ok
235
253
  ? `JSKIT project graph is coordinated across ${result.manifests.length} manifest(s) and ` +
236
254
  `${result.installations.length} resolved package installation(s).`
237
255
  : `JSKIT project graph is inconsistent:\n- ${result.issues.join("\n- ")}`,
238
- MATERIAL3_GATE ? formatMaterial3Report(ui) : formatNavigationReport(ui)
256
+ MATERIAL3_GATE ? formatMaterial3Report(ui) : formatNavigationReport(ui),
257
+ formatTestRunsReport(tests)
239
258
  ];
240
- if (!result.ok || !ui.ok) {
259
+ if (!result.ok || !ui.ok || !tests.ok) {
241
260
  throw new Error(reports.join("\n"));
242
261
  }
243
262
  process.stdout.write(`${reports.join("\n")}\n`);
@@ -2,6 +2,8 @@
2
2
  // so these read Vue, JavaScript and CSS just far enough for the Material 3 rules: blocks, tags with attributes,
3
3
  // declarations, and comments blanked to spaces so every offset still maps to its original line and column.
4
4
 
5
+ import path from "node:path";
6
+
5
7
  const HEX_DIGITS = "[0-9a-fA-F]";
6
8
  // #rgb, #rgba, #rrggbb and #rrggbbaa, not followed by another word character.
7
9
  const HEX_COLOUR = `#(?:${HEX_DIGITS}{8}|${HEX_DIGITS}{6}|${HEX_DIGITS}{3,4})(?![\\w-])`;
@@ -121,6 +123,10 @@ function splitVue(source) {
121
123
 
122
124
  const ATTRIBUTE = /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/gu;
123
125
  const TAG = /<([A-Za-z][\w.-]*)((?:\s+[^\s"'<>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/?)>/gu;
126
+ // An opening or closing tag in a template, with its attributes; HTML void elements never close.
127
+ const TEMPLATE_TAG = /<(\/?)([A-Za-z][\w.-]*)((?:\s+[^\s"'<>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/?)>/gu;
128
+ const VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"]);
129
+ const CONDITIONAL = /(?:^|\s)v-(if|else-if|else)(?=[\s=]|$)/u;
124
130
 
125
131
  function kebab(name) {
126
132
  return name.replace(/([a-z0-9])([A-Z])/gu, "$1-$2").toLowerCase();
@@ -172,6 +178,39 @@ function readTags(template) {
172
178
  return tags;
173
179
  }
174
180
 
181
+ // The v-if chains each element of a template (comments already blanked) renders under, by its offset: a list of
182
+ // [chain, branch] pairs, outermost first. A v-if starts a chain among its siblings, each v-else-if or v-else right
183
+ // after it is the chain's next branch, so two elements on different branches of one chain never render together.
184
+ function readConditionalBranches(template) {
185
+ const branches = new Map();
186
+ const open = [{ name: "", path: [], chain: null }];
187
+ let chains = 0;
188
+ for (const match of template.text.matchAll(TEMPLATE_TAG)) {
189
+ const [, closing, rawName, attributes, selfClosing] = match;
190
+ const name = kebab(rawName);
191
+ if (closing) {
192
+ const index = open.findLastIndex((entry) => entry.name === name);
193
+ if (index > 0) open.length = index;
194
+ continue;
195
+ }
196
+ const parent = open.at(-1);
197
+ const directive = CONDITIONAL.exec(attributes)?.[1];
198
+ if (directive === "if") parent.chain = { id: (chains += 1), branch: 0 };
199
+ else if (directive && parent.chain) parent.chain.branch += 1;
200
+ else parent.chain = null;
201
+ const path = parent.chain ? [...parent.path, [parent.chain.id, parent.chain.branch]] : parent.path;
202
+ if (directive === "else") parent.chain = null;
203
+ branches.set(template.offset + match.index, path);
204
+ if (!selfClosing && !VOID_TAGS.has(name)) open.push({ name, path, chain: null });
205
+ }
206
+ return branches;
207
+ }
208
+
209
+ // Whether two elements sit on different branches of one v-if chain, so at most one of them renders.
210
+ function exclusiveBranches(left, right) {
211
+ return left.some(([chain, branch]) => right.some(([other, otherBranch]) => other === chain && otherBranch !== branch));
212
+ }
213
+
175
214
  // CSS declarations with the selectors that enclose them, for flat CSS, nested SCSS and at-rule blocks.
176
215
  function readDeclarations(css, offset) {
177
216
  const declarations = [];
@@ -207,13 +246,57 @@ function readInlineDeclarations(text, offset) {
207
246
  return readDeclarations(`x{${text}}`, offset - 2);
208
247
  }
209
248
 
249
+ // A default import: `import Name from "specifier"`, its name and specifier.
250
+ const DEFAULT_IMPORT = /\bimport\s+([A-Za-z_$][\w$]*)\s+from\s*["']([^"']+)["']/gu;
251
+
252
+ // The value of the first balanced { ... } or [ ... ] starting at or after `start`.
253
+ function balancedFrom(text, start, open = "{", close = "}") {
254
+ const first = text.indexOf(open, start);
255
+ if (first < 0) return null;
256
+ let depth = 0;
257
+ for (let index = first; index < text.length; index += 1) {
258
+ if (text[index] === open) depth += 1;
259
+ else if (text[index] === close && --depth === 0) return { start: first, end: index + 1, text: text.slice(first, index + 1) };
260
+ }
261
+ return null;
262
+ }
263
+
264
+ // The object literal that encloses `offset`, as { start, end, text }, or null.
265
+ function enclosingObject(text, offset) {
266
+ let depth = 0;
267
+ for (let index = offset; index >= 0; index -= 1) {
268
+ if (text[index] === "}") depth += 1;
269
+ else if (text[index] === "{") {
270
+ if (depth === 0) return balancedFrom(text, index);
271
+ depth -= 1;
272
+ }
273
+ }
274
+ return null;
275
+ }
276
+
277
+ // The app's .vue file an import names: "@/components/Bar.vue" is src/components/Bar.vue; "./Bar" is beside the file.
278
+ function localComponent(file, specifier, known) {
279
+ const base = specifier.startsWith("@/")
280
+ ? path.posix.join("src", specifier.slice(2))
281
+ : path.posix.join(path.posix.dirname(file), specifier);
282
+ return [base, `${base}.vue`].find((candidate) => candidate.endsWith(".vue") && known.has(candidate)) || null;
283
+ }
284
+
210
285
  export {
286
+ DEFAULT_IMPORT,
287
+ TEMPLATE_TAG,
288
+ VOID_TAGS,
211
289
  HEX_COLOUR,
290
+ balancedFrom,
212
291
  blankCssComments,
213
292
  blankHtmlComments,
214
293
  blankJsComments,
294
+ enclosingObject,
295
+ exclusiveBranches,
215
296
  kebab,
216
297
  lineIndex,
298
+ localComponent,
299
+ readConditionalBranches,
217
300
  readDeclarations,
218
301
  readExceptions,
219
302
  readInlineDeclarations,
@@ -15,7 +15,9 @@ import {
15
15
  blankCssComments,
16
16
  blankHtmlComments,
17
17
  blankJsComments,
18
+ exclusiveBranches,
18
19
  lineIndex,
20
+ readConditionalBranches,
19
21
  readDeclarations,
20
22
  readExceptions,
21
23
  readInlineDeclarations,
@@ -51,6 +53,11 @@ const SKIPPED_DIRECTORIES = new Set([
51
53
  "fixtures", "server", "migrations"
52
54
  ]);
53
55
  const SKIPPED_FILE = /\.(?:test|spec)\.[cm]?[jt]s$|\.d\.ts$/u;
56
+ // In JSKIT's own repository, packages/ holds the framework, which implements the navigation, Up and reloads the rules
57
+ // send an app to (ShellUpButton, reloadAppPage(), leaveApp(), the system Back button). A whole-app run reads only the
58
+ // app code a JSKIT package carries, its patterns and templates; an app's own packages are read in full.
59
+ const FRAMEWORK_SCOPE = "@jskit-ai/";
60
+ const FRAMEWORK_APP_CODE = new Set(["patterns", "templates"]);
54
61
  // The generated colour scheme is the one home of hex values.
55
62
  const THEME_DIRECTORY = "theme";
56
63
  // Colours that are content, not UI, keep one value in both schemes, so they are not theme colours (which are toned per
@@ -61,6 +68,12 @@ const WHOLE_APP_ROOTS = Object.freeze(["src", "packages"]);
61
68
 
62
69
  const hexPattern = () => new RegExp(HEX_COLOUR, "gu");
63
70
  const exactHex = new RegExp(`^${HEX_COLOUR}$`, "u");
71
+ // A colour written as channels, as a hand-made shadow is: rgba(0, 0, 0, 0.15), rgb(0 0 0 / 20%), hsl(...). One that
72
+ // reads a theme role, rgba(var(--v-theme-shadow), 0.2), is not.
73
+ const CHANNEL_COLOUR = String.raw`\b(?:rgba?|hsla?)\(\s*[\d.][^()]*\)`;
74
+ const channelColour = () => new RegExp(CHANNEL_COLOUR, "giu");
75
+ const exactChannelColour = new RegExp(`^${CHANNEL_COLOUR}$`, "iu");
76
+ const CHANNEL_FIX = "Use rgb(var(--v-theme-<role>)), with an alpha for a shadow or scrim: rgba(var(--v-theme-shadow), 0.2).";
64
77
  const quotedHex = () => new RegExp(`(["'\`])(${HEX_COLOUR})\\1`, "gu");
65
78
  const MDI_STRING = /\bmdi-[a-z][a-z0-9-]*/gu;
66
79
  const MDI_IMPORT = /(?:from\s*|import\s*|require\(\s*)["'](@mdi\/(?:js|font)[^"']*)["']/gu;
@@ -83,7 +96,18 @@ function isSourceFile(relativePath) {
83
96
  return !relativePath.split("/").slice(0, -1).some(isSkippedDirectory);
84
97
  }
85
98
 
86
- async function walk(projectRoot, directory, files) {
99
+ async function isFrameworkPackage(projectRoot, directory) {
100
+ if (!/^packages\/[^/]+$/u.test(directory)) return false;
101
+ try {
102
+ const manifest = JSON.parse(await readFile(path.join(projectRoot, directory, "package.json"), "utf8"));
103
+ return String(manifest?.name || "").startsWith(FRAMEWORK_SCOPE);
104
+ } catch (error) {
105
+ if (error?.code === "ENOENT") return false;
106
+ throw error;
107
+ }
108
+ }
109
+
110
+ async function walk(projectRoot, directory, files, wholeApp = false) {
87
111
  let entries;
88
112
  try {
89
113
  entries = await readdir(path.join(projectRoot, directory), { withFileTypes: true });
@@ -91,10 +115,12 @@ async function walk(projectRoot, directory, files) {
91
115
  if (error?.code === "ENOENT") return;
92
116
  throw error;
93
117
  }
118
+ const framework = wholeApp && await isFrameworkPackage(projectRoot, directory);
94
119
  for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
95
120
  const relative = directory ? `${directory}/${entry.name}` : entry.name;
121
+ if (framework && !(entry.isDirectory() && FRAMEWORK_APP_CODE.has(entry.name))) continue;
96
122
  if (entry.isDirectory()) {
97
- if (!isSkippedDirectory(entry.name)) await walk(projectRoot, relative, files);
123
+ if (!isSkippedDirectory(entry.name)) await walk(projectRoot, relative, files, wholeApp);
98
124
  } else if (entry.isFile() && isSourceFile(relative)) {
99
125
  files.add(relative);
100
126
  }
@@ -120,7 +146,7 @@ async function collectFiles(projectRoot, paths) {
120
146
  if (paths.length > 0) missing.push(target);
121
147
  continue;
122
148
  }
123
- if (info.isDirectory()) await walk(projectRoot, relative === "" ? "" : relative, files);
149
+ if (info.isDirectory()) await walk(projectRoot, relative === "" ? "" : relative, files, paths.length === 0);
124
150
  else if (isSourceFile(relative)) files.add(relative);
125
151
  }
126
152
  return { files: [...files].sort(), missing };
@@ -164,6 +190,9 @@ function checkCode(code, offset, { report, themed }) {
164
190
  for (const match of code.matchAll(quotedHex())) {
165
191
  report("m3/raw-hex", offset + match.index, `Raw colour ${match[2]}. Use a theme colour role. ${CONTENT_COLOUR}`);
166
192
  }
193
+ for (const match of code.matchAll(channelColour())) {
194
+ report("m3/raw-hex", offset + match.index, `Raw colour ${match[0]}. ${CHANNEL_FIX} ${CONTENT_COLOUR}`);
195
+ }
167
196
  }
168
197
  checkNavigationCode(code, offset, report);
169
198
  }
@@ -198,6 +227,9 @@ function checkDeclaration(declaration, context) {
198
227
  for (const match of declaration.value.matchAll(hexPattern())) {
199
228
  report("m3/raw-hex", declaration.valueOffset + match.index, `Raw colour ${match[0]}. Use rgb(var(--v-theme-<role>)). ${CONTENT_COLOUR}`);
200
229
  }
230
+ for (const match of declaration.value.matchAll(channelColour())) {
231
+ report("m3/raw-hex", declaration.valueOffset + match.index, `Raw colour ${match[0]}. ${CHANNEL_FIX} ${CONTENT_COLOUR}`);
232
+ }
201
233
  }
202
234
  const buttonRule = declaration.selectors.some((selector) => /\.v-btn\b/u.test(selector));
203
235
  if (buttonRule && ["height", "min-height", "--v-btn-height"].includes(declaration.property)) {
@@ -215,7 +247,9 @@ function checkTemplate(template, context) {
215
247
  for (const match of template.text.matchAll(MDI_STRING)) {
216
248
  report("m3/mdi-icon", template.offset + match.index, `MDI icon "${match[0]}". Use a Material Symbols alias such as "$add" from the app's Vuetify icon aliases.`);
217
249
  }
218
- let headings = 0;
250
+ // The branches each h1 renders under: h1s on different branches of one v-if chain never show together.
251
+ const branches = readConditionalBranches(template);
252
+ const headings = [];
219
253
  for (const tag of tags) {
220
254
  for (const entry of tag.attributes) {
221
255
  checkAttribute(tag, entry, context);
@@ -224,9 +258,10 @@ function checkTemplate(template, context) {
224
258
  if (tag.name === "v-btn") checkButtonSize(tag, report);
225
259
  checkBackControl(tag, report, context.script);
226
260
  if (tag.name === "h1") {
227
- headings += 1;
228
- if (headings > 1) report("m3/duplicate-h1", tag.offset, "A second h1 in one template. A page has one title, carried by the top app bar.");
261
+ const branch = branches.get(tag.offset) || [];
262
+ if (headings.some((other) => !exclusiveBranches(other, branch))) report("m3/duplicate-h1", tag.offset, "A second h1 in one template. A page has one title, carried by the top app bar.");
229
263
  else if (context.titled) report("m3/duplicate-h1", tag.offset, "An in-page h1 under the top app bar, which carries the page title; use a title role (text-title-large) for a section title.");
264
+ headings.push(branch);
230
265
  }
231
266
  }
232
267
  }
@@ -239,7 +274,7 @@ function checkAttribute(tag, entry, context) {
239
274
  for (const declaration of readInlineDeclarations(entry.value, entry.valueOffset)) {
240
275
  checkDeclaration({ ...declaration, selectors: tag.name === "v-btn" ? [".v-btn"] : [] }, context);
241
276
  }
242
- } else if (!themed && exactHex.test(entry.value.trim())) {
277
+ } else if (!themed && (exactHex.test(entry.value.trim()) || exactChannelColour.test(entry.value.trim()))) {
243
278
  report("m3/raw-hex", entry.valueOffset, `Raw colour ${entry.value.trim()}. Use a theme colour role such as color="primary". ${CONTENT_COLOUR}`);
244
279
  }
245
280
  checkLinkAttribute(tag, entry, context.report);