@jskit-ai/jskit-catalog 0.2.44 → 0.2.46

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.
@@ -1,8 +1,11 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { ROOT, lintIgnores, sourceFiles } from "./data-access-files.mjs";
3
+ import { ROOT, lintIgnores, sourceFiles, vueFiles } from "./data-access-files.mjs";
4
4
  import { toPosixPath } from "./files.mjs";
5
- import { blankJsComments, lineIndex } from "./material3-source.mjs";
5
+ import {
6
+ assignedVariable, blank, closingOffset, enclosingObject, identifierAt, keyValue, nextCodeOffset, objectArgument, objectAt,
7
+ objectKeys, readSourceFile, readValue, topLevelKeys
8
+ } from "./data-access-source.mjs";
6
9
 
7
10
  // The data part of plain `jskit check`, read from source, never run. It backs up what the lint rules cannot see across
8
11
  // files: every table has exactly one table resource (`defineTableResource({ name, tableName })`) across the app and its
@@ -15,8 +18,10 @@ import { blankJsComments, lineIndex } from "./material3-source.mjs";
15
18
  // too; an eslint-disable or inline eslint config that turns off a data-access rule, or every rule, fails in any file
16
19
  // but the client's; and so does an app file the app's own ESLint config lints without every data-access rule as an
17
20
  // 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.
21
+ // check: it lists what it skipped. Every app table resource a lookup field picks from or a CRUD list screen shows defines
22
+ // searchSchema.q, on the resource or its owns entry, since the picker and the list's Search box send what the person
23
+ // types as q. As information, it lists the fields of each app table resource that its CRUD contracts never name, which
24
+ // no API shows or accepts.
20
25
 
21
26
  const GUIDE = "https://mobily-enterprises.github.io/jskit-ai/guide/framework/data-access";
22
27
  const SECTIONS = Object.freeze({
@@ -27,7 +32,8 @@ const SECTIONS = Object.freeze({
27
32
  "data/runtime-capability": "the-resource-is-the-only-door",
28
33
  "data/lint-disabled": "hard-rules",
29
34
  "data/lint-config": "hard-rules",
30
- "data/unnamed-fields": "layer-2-the-crud-surface"
35
+ "data/unnamed-fields": "layer-2-the-crud-surface",
36
+ "data/search-q": "narrow-in-the-query"
31
37
  });
32
38
  // What each rule asks, said once above its findings in the report.
33
39
  const RULE_EXPLANATIONS = Object.freeze({
@@ -41,6 +47,9 @@ const RULE_EXPLANATIONS = Object.freeze({
41
47
  "takes any other one once the baseline's systemScopes lists it with its reason.",
42
48
  "data/lint-disabled": "neither eslint-disable nor an inline eslint config comment can turn off a data-access rule, and a " +
43
49
  "disable that names no rule turns them all off. Change the code, or name the rules the directive turns off.",
50
+ "data/search-q": "a lookup field's picker and a CRUD list screen's Search box send what the person types as q, so the " +
51
+ "table resource they read defines searchSchema.q (on the resource or its owns entry), searching the fields a person " +
52
+ "would type; without it, every search fails. Searching the rows a page loaded instead misses every row past it.",
44
53
  "data/lint-config": "the app's ESLint config makes every data-access rule an error in each server file, and ignores no " +
45
54
  "server code; a config that turns one off, lowers it or ignores the file hides that code from it. Put the rule back, or, " +
46
55
  "once the module's whole job is data access, list { \"file\", \"rule\" } in the baseline's \"infrastructure\"."
@@ -78,7 +87,7 @@ const CLIENT_FILE = new RegExp(`^${ROOT}src/client/`, "u");
78
87
  // tests, migrations, seeds and build output at a root, seed scripts, and test files.
79
88
  const LINT_EXEMPT = new RegExp(`^${ROOT}(?:(?:src/client|src/test|test|tests|__tests__|e2e|test-support|migrations|seeds?|` +
80
89
  "dist|build|coverage)/|(?:(?:scripts|bin)/)?seed[^/]*\\.[cm]?js$)|\\.(?:test|spec|vitest)\\.[^/]*$", "u");
81
- const MARKERS = new RegExp(`defineTableResource|defineCrudContract|systemScope|systemContext|readable-check: allow|eslint|${CAPABILITY_NAMES}`, "iu");
90
+ const MARKERS = new RegExp(`defineTableResource|defineCrudContract|systemScope|systemContext|readable-check: allow|eslint|lookup|searchSchema|useCrudListScreen|${CAPABILITY_NAMES}`, "iu");
82
91
  const TABLE_RESOURCE_CALL = /\bdefineTableResource\s*\(/gu;
83
92
  const TABLE_RESOURCE_MARKER = /\bdefineTableResource\s*\(/u;
84
93
  // A CRUD contract on a table resource named by its variable: `defineCrudContract(receivalResource, { ... })`.
@@ -96,134 +105,6 @@ const FEATURE_CALL = /\bdefine(?:Crud)?Feature\s*\(/gu;
96
105
  const SYSTEM_CONTEXT_CALL = /\bsystemContext\s*\(/gu;
97
106
  const INFRASTRUCTURE_EXCUSE = /readable-check: allow (?:jskit\/)?(no-direct-knex|no-transaction-reader|no-hand-scope|sql-literal) - (infrastructure:.*?)\s*(?:\*\/)?\s*$/u;
98
107
 
99
- const blank = (text) => text.replace(/[^\n]/gu, " ");
100
-
101
- // Code with its comments and the contents of its strings and templates blanked to spaces, every offset kept: calls
102
- // and keys are found in it, values are read from the code at the same offsets.
103
- function maskStrings(code) {
104
- let output = "";
105
- let index = 0;
106
- while (index < code.length) {
107
- const quote = code[index];
108
- if (quote !== "\"" && quote !== "'" && quote !== "`") {
109
- output += quote;
110
- index += 1;
111
- continue;
112
- }
113
- let end = index + 1;
114
- while (end < code.length && code[end] !== quote && (quote === "`" || code[end] !== "\n")) {
115
- end += code[end] === "\\" ? 2 : 1;
116
- }
117
- output += quote + blank(code.slice(index + 1, end)) + code.slice(end, end + 1);
118
- index = end + 1;
119
- }
120
- return output;
121
- }
122
-
123
- // The offset just past the bracket that closes the one at `start`, in masked code; -1 when it never closes.
124
- function closingOffset(masked, start) {
125
- let depth = 0;
126
- for (let index = start; index < masked.length; index += 1) {
127
- if ("([{".includes(masked[index])) depth += 1;
128
- else if (")]}".includes(masked[index]) && --depth === 0) return index + 1;
129
- }
130
- return -1;
131
- }
132
-
133
- const nextCodeOffset = (text, offset) => offset + (/^\s*/u.exec(text.slice(offset))?.[0].length || 0);
134
-
135
- // The string a top-level `const NAME = "..."` gives, or null.
136
- function constantValue(code, name) {
137
- const match = new RegExp(`(?:^|[\\s;])const\\s+${name}\\s*=\\s*(["'\`])((?:\\\\.|(?!\\1)[^\\\\\\n])*)\\1\\s*[;,\\n]`, "u").exec(code);
138
- return match && !(match[1] === "`" && match[2].includes("${")) ? match[2] : null;
139
- }
140
-
141
- /**
142
- * The value written at an offset, read up to the comma or bracket that ends it: { text } for a string literal, a
143
- * template without expressions, or a const holding one; { text: null } for anything else.
144
- */
145
- function readValue(code, masked, offset) {
146
- const start = nextCodeOffset(code, offset);
147
- const quote = code[start];
148
- if (quote === "\"" || quote === "'" || quote === "`") {
149
- let end = start + 1;
150
- while (end < code.length && code[end] !== quote) end += code[end] === "\\" ? 2 : 1;
151
- const after = masked[nextCodeOffset(masked, end + 1)];
152
- const text = code.slice(start + 1, end);
153
- const whole = after === "," || after === "}" || after === ")";
154
- return { text: whole && !(quote === "`" && text.includes("${")) ? text.replace(/\\(.)/gu, "$1") : null };
155
- }
156
- const identifier = /^[A-Za-z_$][\w$]*(?=\s*[,})])/u.exec(code.slice(start));
157
- return { text: identifier ? constantValue(code, identifier[0]) : null };
158
- }
159
-
160
- // The top-level keys of the object literal between `start` and `end`, in masked code: for each wanted key, the offset
161
- // just past its colon, or "shorthand" for `{ name }`.
162
- function topLevelKeys(masked, start, end, names) {
163
- let depth = 0;
164
- let top = "";
165
- for (let index = start; index < end; index += 1) {
166
- const char = masked[index];
167
- if ("([{".includes(char)) depth += 1;
168
- top += depth === 1 || char === "\n" ? char : " ";
169
- if (")]}".includes(char)) depth -= 1;
170
- }
171
- const keys = new Map();
172
- for (const match of top.matchAll(/(?:^|[{,])\s*([A-Za-z_$][\w$]*)\s*(:|(?=[,}]))/gu)) {
173
- const [whole, key, colon] = match;
174
- if (names.includes(key) && !keys.has(key)) keys.set(key, colon ? start + match.index + whole.length : "shorthand");
175
- }
176
- return keys;
177
- }
178
-
179
- // The object literal a call at `callOffset` takes as its first argument: { start, end } in masked code, or null when the
180
- // call is a declaration (`function defineFeature(`) or its first argument is anything else.
181
- function objectArgument(masked, callOffset, matchLength) {
182
- if (/function\s*\*?\s*$/u.test(masked.slice(Math.max(0, callOffset - 20), callOffset))) return null;
183
- const start = nextCodeOffset(masked, callOffset + matchLength);
184
- if (masked[start] !== "{") return null;
185
- const end = closingOffset(masked, start);
186
- return end < 0 ? null : { start, end };
187
- }
188
-
189
- // The value of one key of an object literal, as readValue reads it; undefined when the object has no such key.
190
- function keyValue(code, masked, keys, key) {
191
- const at = keys.get(key);
192
- if (at === undefined) return undefined;
193
- return at === "shorthand" ? { text: constantValue(code, key) } : readValue(code, masked, at);
194
- }
195
-
196
- function readSourceFile(source) {
197
- const code = blankJsComments(source);
198
- return { source, code, masked: maskStrings(code), position: lineIndex(source) };
199
- }
200
-
201
- // Every key of the object literal between `start` and `end` (masked code), in order, and whether the literal also
202
- // spreads or computes keys, so that its keys are not all known.
203
- function objectKeys(masked, start, end) {
204
- let depth = 0;
205
- let top = "";
206
- for (let index = start; index < end; index += 1) {
207
- const char = masked[index];
208
- if ("([{".includes(char)) depth += 1;
209
- top += depth === 1 || char === "\n" ? char : " ";
210
- if (")]}".includes(char)) depth -= 1;
211
- }
212
- const keys = [...top.matchAll(/(?:^|[{,])\s*([A-Za-z_$][\w$]*)\s*(?::|(?=[,}]))/gu)].map((match) => ({
213
- key: match[1],
214
- at: start + match.index + match[0].length
215
- }));
216
- return { keys, partial: /(?:^|[{,])\s*(?:\.\.\.|["'`[])/u.test(top) };
217
- }
218
-
219
- // The object literal written at an offset: { start, end } in masked code, or null for anything else.
220
- function objectAt(masked, offset) {
221
- const start = nextCodeOffset(masked, offset);
222
- if (masked[start] !== "{") return null;
223
- const end = closingOffset(masked, start);
224
- return end < 0 ? null : { start, end };
225
- }
226
-
227
108
  // A table resource's schema fields that a CRUD contract could show: { names, partial }, or null when the schema is
228
109
  // not an object literal. The ownership fields, the stamped timestamps, the primary key and hidden fields are left out.
229
110
  function readSchemaFields(code, masked, keys, ownership) {
@@ -249,40 +130,142 @@ function readTableResources(file, { code, masked, position }) {
249
130
  for (const match of masked.matchAll(TABLE_RESOURCE_CALL)) {
250
131
  const object = objectArgument(masked, match.index, match[0].length);
251
132
  if (!object) continue;
252
- const keys = topLevelKeys(masked, object.start, object.end, ["name", "tableName", "ownership", "schema", "foreignKeys"]);
133
+ const keys = topLevelKeys(masked, object.start, object.end, ["name", "tableName", "ownership", "schema", "foreignKeys", "searchSchema"]);
253
134
  const ownership = keyValue(code, masked, keys, "ownership")?.text ?? null;
254
135
  resources.push({
255
136
  file,
256
137
  line: position(match.index).line,
257
138
  name: keyValue(code, masked, keys, "name")?.text ?? null,
258
139
  tableName: keyValue(code, masked, keys, "tableName")?.text ?? null,
259
- variable: /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*$/u.exec(masked.slice(Math.max(0, match.index - 120), match.index))?.[1] ?? null,
140
+ variable: assignedVariable(masked, match.index),
260
141
  fields: readSchemaFields(code, masked, keys, ownership),
261
- foreignKeys: keys.has("foreignKeys")
142
+ foreignKeys: keys.has("foreignKeys"),
143
+ searchQ: readSearchQ(masked, keys)
262
144
  });
263
145
  }
264
146
  return resources;
265
147
  }
266
148
 
267
- // Every CRUD contract a file defines: the variable of its table resource and the fields its `fields` names, null when
268
- // they are not all written as literal keys.
149
+ // Whether an object literal's `searchSchema` key defines q: true or false, or null when it is not written as an object
150
+ // literal whose keys are all known. `keys` are the literal's top-level keys (topLevelKeys).
151
+ function readSearchQ(masked, keys) {
152
+ const at = keys.get("searchSchema");
153
+ if (at === undefined) return false;
154
+ const schema = typeof at === "number" ? objectAt(masked, at) : null;
155
+ const named = schema ? objectKeys(masked, schema.start, schema.end) : null;
156
+ if (named?.keys.some(({ key }) => key === "q")) return true;
157
+ return named && !named.partial ? false : null;
158
+ }
159
+
160
+ // Every CRUD contract a file defines: its variable and namespace, the variable of its table resource and the fields its
161
+ // `fields` names, null when they are not all written as literal keys.
269
162
  function readCrudContracts({ code, masked }) {
270
163
  const contracts = [];
271
164
  for (const match of masked.matchAll(CRUD_CONTRACT_CALL)) {
165
+ const variable = assignedVariable(masked, match.index);
272
166
  const config = objectAt(masked, match.index + match[0].length);
273
167
  if (!config) {
274
- contracts.push({ resource: match[1], fields: null });
168
+ contracts.push({ variable, namespace: null, resource: match[1], fields: null });
275
169
  continue;
276
170
  }
277
- const at = topLevelKeys(masked, config.start, config.end, ["fields"]).get("fields");
171
+ const keys = topLevelKeys(masked, config.start, config.end, ["fields", "namespace"]);
172
+ const at = keys.get("fields");
278
173
  const fields = typeof at === "number" ? objectAt(masked, at) : null;
279
174
  const named = fields ? objectKeys(masked, fields.start, fields.end) : null;
280
175
  const known = at === undefined || (named && !named.partial);
281
- contracts.push({ resource: match[1], fields: at === undefined ? [] : known ? named.keys.map(({ key }) => key) : null });
176
+ contracts.push({
177
+ variable,
178
+ namespace: keyValue(code, masked, keys, "namespace")?.text ?? null,
179
+ resource: match[1],
180
+ fields: at === undefined ? [] : known ? named.keys.map(({ key }) => key) : null
181
+ });
282
182
  }
283
183
  return contracts;
284
184
  }
285
185
 
186
+ /**
187
+ * What a file says about searches: the namespaces its lookup fields pick from (`lookup: "<namespace>"`, or a relation
188
+ * `{ kind: "lookup", namespace }`), the table resource variables its owns entries give a searchSchema
189
+ * (`{ resource, searchSchema }`, with searchQ as readSearchQ reads it), and the CRUD contract variables its list screens
190
+ * show (`useCrudListScreen({ resource })`).
191
+ */
192
+ function readSearches(file, { code, masked, position }) {
193
+ const lookups = [];
194
+ for (const match of masked.matchAll(/\b(lookup|kind)\s*:/gu)) {
195
+ const value = readValue(code, masked, match.index + match[0].length).text;
196
+ if (match[1] === "lookup" && value) {
197
+ lookups.push({ file, line: position(match.index).line, namespace: value });
198
+ } else if (match[1] === "kind" && value === "lookup") {
199
+ const object = enclosingObject(masked, match.index);
200
+ const namespace = object && keyValue(code, masked, topLevelKeys(masked, object.start, object.end, ["namespace"]), "namespace")?.text;
201
+ if (namespace) lookups.push({ file, line: position(match.index).line, namespace });
202
+ }
203
+ }
204
+ const owned = [];
205
+ for (const match of masked.matchAll(/\bsearchSchema\s*:/gu)) {
206
+ const object = enclosingObject(masked, match.index);
207
+ const keys = object ? topLevelKeys(masked, object.start, object.end, ["resource", "searchSchema"]) : new Map();
208
+ const at = keys.get("resource");
209
+ const variable = typeof at === "number" ? identifierAt(code, at) : null;
210
+ if (variable) owned.push({ variable, searchQ: readSearchQ(masked, keys) });
211
+ }
212
+ const screens = [];
213
+ for (const match of masked.matchAll(/\buseCrudListScreen\s*\(/gu)) {
214
+ const object = objectArgument(masked, match.index, match[0].length);
215
+ const at = object && topLevelKeys(masked, object.start, object.end, ["resource"]).get("resource");
216
+ const contract = typeof at === "number" ? identifierAt(code, at) : null;
217
+ if (contract) screens.push({ file, line: position(match.index).line, contract });
218
+ }
219
+ return { lookups, owned, screens };
220
+ }
221
+
222
+ // A Vue component's <script> blocks, everything else blanked with its line breaks kept, so offsets and lines hold.
223
+ const vueScripts = (source) => {
224
+ let scripts = blank(source);
225
+ for (const match of source.matchAll(/(<script\b[^>]*>)([\s\S]*?)<\/script>/gu)) {
226
+ const start = match.index + match[1].length;
227
+ scripts = scripts.slice(0, start) + match[2] + scripts.slice(start + match[2].length);
228
+ }
229
+ return scripts;
230
+ };
231
+
232
+ // The resource name a namespace stands for, as @jskit-ai/resource-core's resourceNameFromNamespace makes it:
233
+ // `processing-lots` and `/processing-lots` are `processingLots`.
234
+ const resourceNameFromNamespace = (namespace) => namespace.replace(/^\/+|\/+$/gu, "").replace(/\//gu, "-")
235
+ .replace(/[-_\s]+([A-Za-z0-9])/gu, (_, letter) => letter.toUpperCase());
236
+
237
+ /**
238
+ * Fails each lookup and CRUD list screen whose app table resource defines no searchSchema.q, on the resource or an
239
+ * owns entry. A lookup names its table resource by namespace; a list screen by its CRUD contract's variable. One the
240
+ * check cannot follow (a framework resource, a contract or resource it cannot find, a searchSchema it cannot read) is
241
+ * left unjudged.
242
+ */
243
+ function checkSearchQ(resources, contracts, searches, report) {
244
+ const appResources = resources.filter((resource) => !resource.framework);
245
+ const ownedQ = Map.groupBy(searches.flatMap(({ owned }) => owned), ({ variable }) => variable);
246
+ // An unreadable searchSchema (null) counts as one that defines q.
247
+ const searchQ = (resource) => resource.searchQ !== false || (ownedQ.get(resource.variable) || []).some(({ searchQ: q }) => q !== false);
248
+ const unique = (list) => (list.length === 1 ? list[0] : null);
249
+ const where = (resource) => `${resource.name} (${resource.file}:${resource.line})`;
250
+ const fix = "give it searchSchema: { q: { type: \"string\", oneOf: [<the fields a person types>], filterOperator: \"like\" } }.";
251
+ for (const lookup of searches.flatMap(({ lookups }) => lookups)) {
252
+ const name = resourceNameFromNamespace(lookup.namespace);
253
+ const resource = unique(appResources.filter((entry) => entry.name === name));
254
+ if (resource && !searchQ(resource)) {
255
+ report(lookup, "data/search-q", `lookup "${lookup.namespace}" picks from ${where(resource)}, which defines no ` +
256
+ `searchSchema.q: the picker sends what the person types as q, so ${fix}`);
257
+ }
258
+ }
259
+ for (const screen of searches.flatMap(({ screens }) => screens)) {
260
+ const contract = unique(contracts.filter((entry) => entry.variable === screen.contract));
261
+ const resource = contract && unique(appResources.filter((entry) => entry.variable === contract.resource));
262
+ if (resource && !searchQ(resource)) {
263
+ report(screen, "data/search-q", `this list screen shows ${screen.contract}, whose table resource ${where(resource)} ` +
264
+ `defines no searchSchema.q: the list's Search box sends what the person types as q, so ${fix}`);
265
+ }
266
+ }
267
+ }
268
+
286
269
  // The fields of each app table resource that its CRUD contracts never name, for a resource with at least one contract
287
270
  // whose fields are all known.
288
271
  function unnamedFields(resources, contracts) {
@@ -568,6 +551,7 @@ async function checkDataAccess({ projectRoot = process.cwd() } = {}) {
568
551
  const scopes = [];
569
552
  const excuses = [];
570
553
  const capabilities = [];
554
+ const searches = [];
571
555
  const findings = [];
572
556
  const report = ({ file, line }, rule, message) => findings.push({ file, line, rule, message });
573
557
  for (const { file, absolute, framework } of sources) {
@@ -579,6 +563,7 @@ async function checkDataAccess({ projectRoot = process.cwd() } = {}) {
579
563
  scopes.push(...readSystemScopes(name, read).map((scope) => ({ ...scope, framework })));
580
564
  if (framework) continue;
581
565
  contracts.push(...readCrudContracts(read));
566
+ searches.push(readSearches(name, read));
582
567
  const fileExcuses = readInfrastructureExcuses(name, source);
583
568
  excuses.push(...fileExcuses);
584
569
  capabilities.push(...readRuntimeCapabilities(name, read).map((use) => ({ ...use, infrastructure: fileExcuses.length > 0 })));
@@ -589,7 +574,12 @@ async function checkDataAccess({ projectRoot = process.cwd() } = {}) {
589
574
  : disable.rules.length > 0 ? `eslint-disable turns off ${disable.rules.join(", ")}` : "eslint-disable names no rule");
590
575
  }
591
576
  }
577
+ for (const { file, absolute } of await vueFiles(root)) {
578
+ const source = await readFile(absolute, "utf8");
579
+ if (source.includes("useCrudListScreen")) searches.push(readSearches(file, readSourceFile(vueScripts(source))));
580
+ }
592
581
  checkTables(resources, report);
582
+ checkSearchQ(resources, contracts, searches, report);
593
583
  checkSystemScopes(scopes, report);
594
584
  const baseline = await readBaseline(root, report);
595
585
  checkBaseline(baseline, excuses, scopes, report);
package/scripts/jskit.mjs CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  formatExportSummary
13
13
  } from "./i18n.mjs";
14
14
  import { checkDataAccess, formatDataAccessReport } from "./data-access.mjs";
15
+ import { checkManifestCapabilities, formatManifestCapabilitiesReport } from "./manifest-capabilities.mjs";
15
16
  import { checkMaterial3, formatMaterial3Report } from "./material3.mjs";
16
17
  import { checkProject, updateProject } from "./project-packages.mjs";
17
18
  import { runTests } from "./test-command.mjs";
@@ -50,7 +51,10 @@ function usage() {
50
51
  " installed JSKIT packages share a table or a name (data/one-resource-per-table), or when a system",
51
52
  " scope (a feature's systemScope, a systemContext() call) gives no literal reason (data/system-scope),",
52
53
  " and it lists every system scope and every file the infrastructure exception lifts past the",
53
- " data-access lint rules for review.",
54
+ " data-access lint rules for review. It fails when an application package's manifest",
55
+ " (jskit.capabilities) misses a capability its features (server providers) provide or require, or",
56
+ " provides one none of them does (manifest/capabilities), and lists each requirement no feature has,",
57
+ " which only install time may need, for review.",
54
58
  " check m3 Check application UI source against the JSKIT Material 3 guide alone, with each finding's guide",
55
59
  " section and every exception: the whole app, or only the given files and directories (a section,",
56
60
  " or a task's changed files). Plain jskit check applies the same rules to the whole app.",
@@ -272,6 +276,7 @@ async function main(argv = process.argv.slice(2)) {
272
276
  const ui = await checkMaterial3({ projectRoot: options.projectRoot });
273
277
  const tests = await checkTestRuns({ projectRoot: options.projectRoot, manifests: result.manifests });
274
278
  const data = await checkDataAccess({ projectRoot: options.projectRoot });
279
+ const capabilities = await checkManifestCapabilities({ projectRoot: options.projectRoot, manifests: result.manifests });
275
280
  // The data rules first: a graph that fails on many pin versions would otherwise bury them.
276
281
  const reports = [
277
282
  formatDataAccessReport(data),
@@ -280,9 +285,10 @@ async function main(argv = process.argv.slice(2)) {
280
285
  `${result.installations.length} resolved package installation(s).`
281
286
  : `JSKIT project graph is inconsistent:\n- ${result.issues.join("\n- ")}`,
282
287
  formatMaterial3Report(ui),
283
- formatTestRunsReport(tests)
288
+ formatTestRunsReport(tests),
289
+ formatManifestCapabilitiesReport(capabilities)
284
290
  ];
285
- if (!result.ok || !ui.ok || !tests.ok || !data.ok) {
291
+ if (!result.ok || !ui.ok || !tests.ok || !data.ok || !capabilities.ok) {
286
292
  throw new Error(reports.join("\n"));
287
293
  }
288
294
  process.stdout.write(`${reports.join("\n")}\n`);
@@ -0,0 +1,89 @@
1
+ import { createRequire } from "node:module";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { toPosixPath } from "./files.mjs";
5
+
6
+ // The manifest part of plain `jskit check`: an application package's manifest (`jskit.capabilities` in its
7
+ // package.json) names what its features (the server providers `jskit.runtime.server.providers` declares) provide and
8
+ // require. The server refuses to start on a drifted one; here it fails the check instead, with the capability to add or
9
+ // remove. The kernel the package itself resolves loads the features and compares them, by the rules it applies at
10
+ // start-up, so the two never disagree.
11
+
12
+ const GUIDE = "https://mobily-enterprises.github.io/jskit-ai/guide/framework/typed-features#package-manifest";
13
+ const KERNEL_PLATFORM = "@jskit-ai/kernel/server/platform";
14
+
15
+ // The line naming a capability in a manifest's `capabilities` block, else the block's own line.
16
+ function capabilityLine(contents, capabilityId = "") {
17
+ const text = String(contents);
18
+ const block = text.indexOf('"capabilities"');
19
+ if (block < 0) return 1;
20
+ const named = capabilityId ? text.indexOf(`"${capabilityId}"`, block) : -1;
21
+ return text.slice(0, named < 0 ? block : named).split("\n").length;
22
+ }
23
+
24
+ // The kernel's platform module as the package resolves it, else as the app does; null when neither has one.
25
+ async function kernelPlatform(manifestPath, root) {
26
+ for (const base of [manifestPath, path.join(root, "package.json")]) {
27
+ let resolved;
28
+ try {
29
+ resolved = createRequire(base).resolve(KERNEL_PLATFORM);
30
+ } catch {
31
+ continue;
32
+ }
33
+ return await import(pathToFileURL(resolved).href);
34
+ }
35
+ return null;
36
+ }
37
+
38
+ async function checkManifestCapabilities({ projectRoot, manifests }) {
39
+ const root = path.resolve(projectRoot);
40
+ const findings = [];
41
+ const installTime = [];
42
+ let packages = 0;
43
+ for (const manifest of manifests) {
44
+ const jskit = manifest.value?.jskit;
45
+ if (!Array.isArray(jskit?.runtime?.server?.providers) || jskit.runtime.server.providers.length < 1) continue;
46
+ const file = toPosixPath(path.relative(root, manifest.absolutePath)) || "package.json";
47
+ const at = (capabilityId, message) => findings.push({ file, line: capabilityLine(manifest.contents, capabilityId), rule: "manifest/capabilities", message });
48
+ const kernel = await kernelPlatform(manifest.absolutePath, root);
49
+ if (typeof kernel?.packageCapabilityDrift !== "function") {
50
+ at("", `declares server providers, but no installed ${KERNEL_PLATFORM} compares a manifest with them; run jskit update.`);
51
+ continue;
52
+ }
53
+ let drift;
54
+ try {
55
+ drift = await kernel.packageCapabilityDrift({
56
+ packageId: String(manifest.value.name || file),
57
+ packageRoot: path.dirname(manifest.absolutePath),
58
+ packageMetadata: jskit
59
+ });
60
+ } catch (error) {
61
+ at("", `declares server providers that do not load: ${error instanceof Error ? error.message : String(error)}`);
62
+ continue;
63
+ }
64
+ if (!drift) continue;
65
+ packages += 1;
66
+ for (const id of drift.unlistedProvides) at("", `its features provide ${id}, which jskit.capabilities.provides does not list; add it.`);
67
+ for (const id of drift.extraProvides) at(id, `jskit.capabilities.provides lists ${id}, which none of its features provides; remove it.`);
68
+ for (const id of drift.unlistedRequires) at("", `its features require ${id}, which jskit.capabilities.requires does not list; add it.`);
69
+ for (const id of drift.extraRequires) installTime.push({ file, line: capabilityLine(manifest.contents, id), capabilityId: id });
70
+ }
71
+ findings.sort((left, right) => left.file.localeCompare(right.file) || left.line - right.line);
72
+ return { ok: findings.length < 1, findings, installTime, packages };
73
+ }
74
+
75
+ function formatManifestCapabilitiesReport(result) {
76
+ const lines = result.findings.flatMap(({ file, line, rule, message }) => [`${file}:${line} ${rule} ${message}`, ` ${GUIDE}`]);
77
+ if (result.installTime.length > 0) {
78
+ lines.push(`Manifest requirements no feature has (${result.installTime.length}), each needed only at install time, such as ` +
79
+ `the database a migration needs; remove any other (${GUIDE}):`);
80
+ for (const { file, line, capabilityId } of result.installTime) lines.push(` ${file}:${line} ${capabilityId}`);
81
+ }
82
+ lines.push(result.ok
83
+ ? `JSKIT package manifests name what their features provide and require in ${result.packages} package(s).`
84
+ : `JSKIT package manifests: ${result.findings.length} error(s). A package's jskit.capabilities lists exactly what its ` +
85
+ "features provide, and every capability they require beyond the kernel's own and the package's.");
86
+ return lines.join("\n");
87
+ }
88
+
89
+ export { checkManifestCapabilities, formatManifestCapabilitiesReport };