qtools-library-dot-d 1.0.13 → 1.1.0

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/README.md CHANGED
@@ -227,8 +227,96 @@ The dotD pattern works particularly well in:
227
227
 
228
228
  This pattern is used extensively in the qtools ecosystem for building scalable, maintainable Node.js applications.
229
229
 
230
+ ## Loading from several directories
231
+
232
+ *New in 1.1.0. Opt-in: nothing changes for a library that uses `setLibraryPath`.*
233
+
234
+ A library can load from an ordered list of roots, so that one installation (an *instance*) can add its own modules beside the shared *framework* ones, and replace a framework module only by saying so.
235
+
236
+ ```javascript
237
+ const screenersDotD = qtLibDotD({ libraryName: 'corpusScreeners' });
238
+
239
+ screenersDotD.setInterface({ // optional; checks every element
240
+ describeFunctionName: 'describeCorpusScreener',
241
+ requiredFunctionList: ['screenCorpus'],
242
+ nameFieldName: 'corpusScreenerName',
243
+ versionFieldName: 'corpusScreenerVersion',
244
+ contractVersion: 1,
245
+ });
246
+
247
+ screenersDotD.setForbiddenRequireCheck({ // optional; static scan of instance source
248
+ provenanceList: ['instance'],
249
+ allowedModuleList: require('module').builtinModules,
250
+ });
251
+
252
+ screenersDotD.setLibraryPathList([
253
+ { dirPath: instanceScreenersPath, provenance: 'instance', mayBeAbsent: true },
254
+ { dirPath: frameworkScreenersPath, provenance: 'framework' },
255
+ ]);
256
+
257
+ screenersDotD.loadModules({ passThroughParameters }, (err) => {
258
+ // err names every refusal, one per line; '' when all loaded
259
+ });
260
+ ```
261
+
262
+ ### The rules
263
+
264
+ - **Entries.** `{ dirPath, provenance: 'instance' | 'framework', mayBeAbsent }`. A plain string is a framework root that must exist. `mayBeAbsent` is optional and false when omitted.
265
+ - **Order.** Framework roots load first, then instance roots, whatever order the list gives them in.
266
+ - **Missing roots.** Refused as `PLUGIN_ROOT_NOT_FOUND` unless `mayBeAbsent` is true, in which case the log says `ROOT_ABSENT_ALLOWED`.
267
+ - **What is a module.** A `.js`/`.cjs`/`.mjs` file or a directory (`require()` must resolve it: `package.json` `main` or `index.js`). Dotfiles and other files are skipped and logged as `SKIPPED_NON_MODULE`; in single-path mode they would be "ignoring invalid file" lines.
268
+ - **One name, one element.** A name added a second time is refused as `PLUGIN_NAME_COLLISION`, naming both files, unless an instance element replaces a framework element **and declares it**:
269
+
270
+ ```javascript
271
+ overridesFramework: { replacesVersion: '1', reason: 'why this instance needs its own' }
272
+ ```
273
+
274
+ The declaration lives in the element's describe output when an interface is set, otherwise on the element itself (an object or function property). It needs both fields, or it is refused as `PLUGIN_OVERRIDE_DECLARATION_INVALID`. Two framework elements, or two instance elements, of one name always collide.
275
+ - **Stale overrides warn.** When `replacesVersion` is not strictly equal to the framework element's declared version (`versionFieldName`), the override is accepted and the log says `OVERRIDE_TARGET_CHANGED`. Without an interface there is no framework version to compare, and the log says `OVERRIDE_TARGET_UNVERSIONED`. A declaration with nothing to replace logs `OVERRIDE_TARGET_ABSENT`.
276
+ - **Interface check.** With `setInterface`, all five fields are required (`INTERFACE_SPEC_INCOMPLETE`). Each element must carry the describe function and every required function (`PLUGIN_INTERFACE_INCOMPLETE`), describe itself by the name it was added under (`PLUGIN_NAME_MISMATCH`), and declare the library's `contractVersion` (`PLUGIN_CONTRACT_MISMATCH`).
277
+ - **Load errors are refusals.** A module whose `require()` throws is refused as `PLUGIN_LOAD_FAILED`, naming the file. `loadModules({ passThroughParameters, tolerateLoadErrors: true })` lists it in the exception list instead, still as `PLUGIN_LOAD_FAILED`.
278
+ - **Forbidden requires.** With `setForbiddenRequireCheck`, the source of every module from a listed provenance is scanned before it runs. A refused module is never executed (`PLUGIN_FORBIDDEN_REQUIRE`).
279
+ - **What is allowed:**
280
+ - names in `allowedModuleList` (a `node:` prefix is ignored);
281
+ - relative or absolute paths whose resolved file, judged by its real path after symlinks, stays inside the plugin's own directory. For a single-file module, that is its root.
282
+ - **What is refused:**
283
+ - a path that resolves to a non-module file, such as `require('./payload.txt')`, which CommonJS would run as JavaScript;
284
+ - a path that does not resolve at all;
285
+ - a module whose own real path, or `package.json` `main`, lies outside its directory.
286
+ - **What the scan cannot see.** The scan reads every `.js`/`.cjs`/`.mjs` file in a directory module, recursively (this needs Node 20 or later). It is a regular expression over string-literal `require('…')` and static template calls. It does **not** catch:
287
+ - a require whose name is built at run time: a variable, string concatenation, or a `${}` template;
288
+ - `import()`;
289
+ - `module.createRequire` (`module` is a built-in, so allowed).
290
+
291
+ It also refuses a `require('…')` that appears only in a comment. That is a false refusal, not a missed one.
292
+ - **Why the check exists.** It serves portability (spec R6), not security: it is not a sandbox.
293
+ - **Reserved names.** An element may not take a library API name such as `toString` or `getProvenance`, nor a name `Object.prototype` carries (`constructor`, `hasOwnProperty`, `__proto__`, and the `qt…` methods qtools-functional-library installs there). Elements are properties of the library object itself, so such a name would shadow a method the library relies on: `qtSelectProperties` would break `seal()`. Refused as `PLUGIN_NAME_RESERVED`.
294
+ - **Adding late.** `library.add()` after loading has finished throws `PLUGIN_ADD_OUTSIDE_LOAD`: there is no file to attribute it to and no callback left to report it. A module must register synchronously, while it is being required. A registration from a timer or a promise throws there, uncaught, and stops the process.
295
+ - **Load once.** A second `loadModules` on a path-list library is refused (`LOAD_MODULES_REPEATED`); single-path libraries still simply re-add.
296
+ - **Directory modules need an entry point.** `require(<dir>)` resolves `package.json` `main` or `index.js`. A layout like `screeners.d/<name>/<name>.js` without one of them is `PLUGIN_LOAD_FAILED`.
297
+ - **A loadModules error is fatal.** Refusals are collected, and `loadModules` calls back with all of them. Elements that passed stay loaded, so after an error the library is partly populated. Treat the error as fatal and do not use the library.
298
+ - **Silent modules.** A module that loads but adds nothing is logged as `MODULE_ADDED_NOTHING`.
299
+ - **Not mixable.** One library uses either `setLibraryPath` or `setLibraryPathList` (`LIBRARY_PATH_MODE_CONFLICT`). `setInterface` and `setForbiddenRequireCheck` need a path list (`REQUIRES_PATH_LIST`), and so does the default add: `setCustomAddFunction` with a path list is refused (`CUSTOM_ADD_REQUIRES_SINGLE_PATH`).
300
+ - **Without a callback,** `setLibraryPathList` and `loadModules` throw the refusal string, as `setLibraryPath` throws a bad path.
301
+ - **Sealing.** `seal()` removes the setters, `library` and `logList`, as it always has. For a path-list library it also removes `setLibraryPathList`, `setInterface` and `setForbiddenRequireCheck`. The getters remain.
302
+
303
+ ### Provenance
304
+
305
+ Every element records `{ provenance, filePath, overrides? }`:
306
+
307
+ ```javascript
308
+ screenersDotD.getProvenance('transcriptDetector');
309
+ // { provenance: 'instance', filePath: '…/corpusScreeners.d/transcriptDetector',
310
+ // overrides: { provenance: 'framework', filePath: '…', version: '1', reason: '…' } }
311
+ screenersDotD.getProvenanceList(); // [{ elementName, provenance, filePath, overrides? }, …]
312
+ screenersDotD.getRefusalList(); // [{ refusalCode, elementName, filePath, message }, …]
313
+ ```
314
+
315
+ `toString()` adds the same on each element line, e.g. `transcriptDetector: object [instance: …; overrides framework v1 (…) because …]`. `getProvenance` throws `PROVENANCE_NOT_RECORDED` for a single-path library and `PROVENANCE_UNKNOWN_NAME` for a name it does not have. Warnings (`OVERRIDE_TARGET_…`, `ROOT_ABSENT_ALLOWED`, `SKIPPED_NON_MODULE`, `MODULE_ADDED_NOTHING`) are in `qtdProcessLog()`.
316
+
230
317
  ## HISTORY
231
318
 
319
+ v1.1.0 - setLibraryPathList: framework and instance roots, declared overrides, opt-in interface and forbidden-require checks, provenance, named refusals; Object.prototype names reserved in path-list mode. setLibraryPath unchanged. Tests added (npm test); test/ not shipped in the package.
232
320
  v1.13 - License changed to MIT (was ISC). No code changes.
233
321
  v1.12 - clarified error message "Ignoring invalid file".
234
322
  v1.10 - there was a reason I did not return the exception list. Undone.
package/package.json CHANGED
@@ -1,10 +1,16 @@
1
1
  {
2
2
  "name": "qtools-library-dot-d",
3
- "version": "1.0.13",
3
+ "version": "1.1.0",
4
4
  "description": "Scan a directory for JS modules that can execute and addMe() that attaches it to some form of name/value list, eg, a Map() with functions as values or an xPressJS route/endpoint list.",
5
5
  "main": "qtools-library-dot-d.js",
6
+ "files": [
7
+ "qtools-library-dot-d.js",
8
+ "README.md",
9
+ "README2.md",
10
+ "LICENSE"
11
+ ],
6
12
  "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
13
+ "test": "node --test test/*.test.js"
8
14
  },
9
15
  "author": "TQ White II",
10
16
  "license": "MIT",
@@ -12,6 +12,11 @@ const fs = require('fs');
12
12
  const qt = require('qtools-functional-library');
13
13
 
14
14
  //START OF moduleFunction() ============================================================
15
+ // Two ways to load. setLibraryPath(dirPath) is the original single directory, and behaves exactly as in 1.0.13,
16
+ // including swallowing a module's load error into the exception list. setLibraryPathList(pathList) loads framework
17
+ // roots, then instance roots, and refuses by name what single-path mode would let pass silently: name collisions,
18
+ // undeclared overrides, interface failures, load errors and (opt-in) forbidden requires. See README, "Loading from
19
+ // several directories".
15
20
  const moduleFunction =
16
21
  ({ moduleName } = {}) =>
17
22
  ({ libraryName = 'libraryDotD' }) => {
@@ -22,14 +27,19 @@ const moduleFunction =
22
27
  // SET LIBRARY PATH
23
28
 
24
29
  let libraryPath;
30
+ let libraryPathMode = ''; // 'singlePath' (setLibraryPath) or 'pathList' (setLibraryPathList); never both
25
31
 
26
32
  const setLibraryPath = (dirPath, callback) => {
33
+ if (libraryPathMode == 'pathList') {
34
+ throw `LIBRARY_PATH_MODE_CONFLICT: ${libraryName} already has a path list; setLibraryPath(${dirPath}) refused`;
35
+ }
27
36
  try {
28
37
  libraryPath = fs.realpathSync(dirPath);
29
38
  } catch (err) {
30
39
  throw err.toString();
31
40
  return;
32
41
  }
42
+ libraryPathMode = 'singlePath';
33
43
 
34
44
  if (typeof callback == 'function') {
35
45
  callback('', libraryPath);
@@ -38,6 +48,149 @@ const moduleFunction =
38
48
  }
39
49
  };
40
50
 
51
+ // --------------------------------------------------------------------------------
52
+ // SET LIBRARY PATH LIST (instance and framework roots)
53
+
54
+ /**
55
+ * One root in a path list. A plain string means { dirPath, provenance: 'framework', mayBeAbsent: false }.
56
+ * @typedef {Object} PathListEntry
57
+ * @property {string} dirPath
58
+ * @property {'instance'|'framework'} provenance - framework roots load first, then instance roots
59
+ * @property {boolean} [mayBeAbsent=false] - a missing root is refused (PLUGIN_ROOT_NOT_FOUND) unless true
60
+ */
61
+
62
+ /**
63
+ * The contract every element of an interface-checked library must meet (setInterface).
64
+ * @interface InterfaceSpec
65
+ * @property {string} describeFunctionName - element[describeFunctionName]() returns its description object
66
+ * @property {string[]} requiredFunctionList - functions every element must carry
67
+ * @property {string} nameFieldName - description field that must equal the name passed to library.add
68
+ * @property {string} versionFieldName - description field holding the element's own version
69
+ * @property {*} contractVersion - description.contractVersion must equal this (strict ===)
70
+ */
71
+
72
+ /**
73
+ * Where an element came from (getProvenance). overrides is present only for a declared instance override.
74
+ * @typedef {Object} ProvenanceRecord
75
+ * @property {'instance'|'framework'} provenance
76
+ * @property {string} filePath - the module (file or directory) whose require() added the element
77
+ * @property {{provenance: string, filePath: string, version: *, reason: string}} [overrides]
78
+ */
79
+
80
+ const provenanceNameList = ['instance', 'framework'];
81
+ let libraryPathList = [];
82
+
83
+ // Refusals in the setters are thrown when no callback is given, matching setLibraryPath.
84
+ const deliverResult = (errString, result, callback) => {
85
+ if (typeof callback == 'function') {
86
+ callback(errString, result);
87
+ return;
88
+ }
89
+ if (errString) {
90
+ throw errString;
91
+ }
92
+ return result;
93
+ };
94
+
95
+ const normalizePathListEntry = (pathListEntry, entryIndex) => {
96
+ if (typeof pathListEntry == 'string') {
97
+ return { dirPath: pathListEntry, provenance: 'framework', mayBeAbsent: false };
98
+ }
99
+ const { dirPath, provenance, mayBeAbsent = false } = pathListEntry || {};
100
+ const problemList = [
101
+ typeof dirPath == 'string' && dirPath ? '' : 'dirPath must be a non-empty string',
102
+ provenanceNameList.includes(provenance) ? '' : `provenance must be one of ${provenanceNameList.join(', ')} (got ${JSON.stringify(provenance)})`,
103
+ typeof mayBeAbsent == 'boolean' ? '' : `mayBeAbsent must be boolean (got ${JSON.stringify(mayBeAbsent)})`,
104
+ ].filter((problem) => problem);
105
+ if (problemList.length) {
106
+ return { entryProblem: `PATH_LIST_ENTRY_INVALID: ${libraryName} entry ${entryIndex}: ${problemList.join('; ')}` };
107
+ }
108
+ return { dirPath, provenance, mayBeAbsent };
109
+ };
110
+
111
+ const setLibraryPathList = (pathList, callback) => {
112
+ if (libraryPathMode == 'singlePath') {
113
+ return deliverResult(`LIBRARY_PATH_MODE_CONFLICT: ${libraryName} already has a single library path; setLibraryPathList refused`, undefined, callback);
114
+ }
115
+ if (!Array.isArray(pathList) || !pathList.length) {
116
+ return deliverResult(`PATH_LIST_INVALID: ${libraryName} setLibraryPathList needs a non-empty array (got ${JSON.stringify(pathList)})`, undefined, callback);
117
+ }
118
+
119
+ const normalizedEntryList = pathList.map(normalizePathListEntry);
120
+ const problemList = normalizedEntryList
121
+ .filter(({ entryProblem }) => entryProblem)
122
+ .map(({ entryProblem }) => entryProblem);
123
+
124
+ const resolvedEntryList = normalizedEntryList
125
+ .filter(({ entryProblem }) => !entryProblem)
126
+ .map((pathListEntry) => {
127
+ const { dirPath, provenance, mayBeAbsent } = pathListEntry;
128
+ const rootExists = fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory();
129
+ if (rootExists) {
130
+ return { dirPath: fs.realpathSync(dirPath), provenance, mayBeAbsent, rootIsAbsent: false };
131
+ }
132
+ if (mayBeAbsent) {
133
+ logList.push(`ROOT_ABSENT_ALLOWED: ${provenance} root ${dirPath} is not an existing directory; nothing loaded from it`);
134
+ return { dirPath, provenance, mayBeAbsent, rootIsAbsent: true };
135
+ }
136
+ problemList.push(`PLUGIN_ROOT_NOT_FOUND: ${libraryName} ${provenance} root ${dirPath} is not an existing directory`);
137
+ return { dirPath, provenance, mayBeAbsent, rootIsAbsent: true };
138
+ });
139
+
140
+ if (problemList.length) {
141
+ return deliverResult(problemList.join('\n'), undefined, callback);
142
+ }
143
+
144
+ libraryPathList = resolvedEntryList;
145
+ libraryPathMode = 'pathList';
146
+ return deliverResult('', libraryPathList.map((pathListEntry) => ({ ...pathListEntry })), callback);
147
+ };
148
+
149
+ // --------------------------------------------------------------------------------
150
+ // SET INTERFACE (opt-in check of every added item, path-list mode only)
151
+
152
+ let interfaceSpec;
153
+
154
+ const setInterface = (newInterfaceSpec) => {
155
+ const { describeFunctionName, requiredFunctionList, nameFieldName, versionFieldName, contractVersion } =
156
+ newInterfaceSpec || {};
157
+ const isNameString = (candidate) => typeof candidate == 'string' && candidate;
158
+ const problemList = [
159
+ isNameString(describeFunctionName) ? '' : 'describeFunctionName',
160
+ Array.isArray(requiredFunctionList) && requiredFunctionList.every(isNameString) ? '' : 'requiredFunctionList (array of function names)',
161
+ isNameString(nameFieldName) ? '' : 'nameFieldName',
162
+ isNameString(versionFieldName) ? '' : 'versionFieldName',
163
+ contractVersion !== undefined && contractVersion !== null ? '' : 'contractVersion',
164
+ ].filter((problem) => problem);
165
+ if (problemList.length) {
166
+ throw `INTERFACE_SPEC_INCOMPLETE: ${libraryName} setInterface is missing or has invalid: ${problemList.join(', ')}`;
167
+ }
168
+ interfaceSpec = { describeFunctionName, requiredFunctionList: [...requiredFunctionList], nameFieldName, versionFieldName, contractVersion };
169
+ return { ...interfaceSpec };
170
+ };
171
+
172
+ // --------------------------------------------------------------------------------
173
+ // SET FORBIDDEN REQUIRE CHECK (opt-in static scan, path-list mode only)
174
+
175
+ let forbiddenRequireSpec;
176
+
177
+ const setForbiddenRequireCheck = (newForbiddenRequireSpec) => {
178
+ const { provenanceList, allowedModuleList } = newForbiddenRequireSpec || {};
179
+ const problemList = [
180
+ Array.isArray(provenanceList) && provenanceList.length && provenanceList.every((provenance) => provenanceNameList.includes(provenance))
181
+ ? ''
182
+ : `provenanceList must be a non-empty array of ${provenanceNameList.join(', ')}`,
183
+ Array.isArray(allowedModuleList) && allowedModuleList.every((allowedModuleName) => typeof allowedModuleName == 'string')
184
+ ? ''
185
+ : 'allowedModuleList must be an array of module names',
186
+ ].filter((problem) => problem);
187
+ if (problemList.length) {
188
+ throw `FORBIDDEN_REQUIRE_SPEC_INVALID: ${libraryName} setForbiddenRequireCheck: ${problemList.join('; ')}`;
189
+ }
190
+ forbiddenRequireSpec = { provenanceList: [...provenanceList], allowedModuleList: [...allowedModuleList] };
191
+ return { ...forbiddenRequireSpec };
192
+ };
193
+
41
194
  // --------------------------------------------------------------------------------
42
195
  // SET CUSTOM MODULE ADDING FUNCTION
43
196
 
@@ -86,12 +239,161 @@ const moduleFunction =
86
239
  const exceptionList = [];
87
240
  const library = (() => {
88
241
  const add = (name, item) => {
242
+ if (libraryPathMode == 'pathList') {
243
+ addFromPathList(name, item);
244
+ return;
245
+ }
89
246
  libraryObject[name] = item; //used only for producing display listings
90
247
  externalElements[name] = item;
91
248
  };
92
249
  return { add };
93
250
  })();
94
251
 
252
+ // ======================================================================================
253
+ // PATH LIST ADD (collision, declared override, interface check, provenance)
254
+
255
+ const refusalList = [];
256
+ // No prototype: an element named 'constructor' or 'valueOf' must not find Object.prototype's member.
257
+ const provenanceByName = Object.create(null); // { provenance, filePath, overrides? } per element
258
+ const declaredVersionByName = Object.create(null);
259
+ let loadContext; // { provenance, filePath, addedNameList } while one module is being required
260
+ let apiNameList = []; // the library's own API names; filled once externalElements exists
261
+ let reservedNameList = []; // apiNameList plus Object.prototype's members
262
+
263
+ const refuse = ({ refusalCode, elementName, filePath, message }) =>
264
+ refusalList.push({ refusalCode, elementName, filePath, message: `${refusalCode}: ${message}` });
265
+
266
+ const checkInterface = (elementName, item) => {
267
+ const { describeFunctionName, requiredFunctionList, nameFieldName, contractVersion } = interfaceSpec;
268
+ const missingFunctionList = [describeFunctionName, ...requiredFunctionList].filter(
269
+ (functionName) => item === null || item === undefined || typeof item[functionName] != 'function',
270
+ );
271
+ if (missingFunctionList.length) {
272
+ return { refusalCode: 'PLUGIN_INTERFACE_INCOMPLETE', message: `'${elementName}' lacks required function(s): ${missingFunctionList.join(', ')}` };
273
+ }
274
+ const describeOutput = item[describeFunctionName]();
275
+ if (describeOutput === null || typeof describeOutput != 'object') {
276
+ return { refusalCode: 'PLUGIN_INTERFACE_INCOMPLETE', message: `'${elementName}' ${describeFunctionName}() did not return an object` };
277
+ }
278
+ if (describeOutput[nameFieldName] !== elementName) {
279
+ return {
280
+ refusalCode: 'PLUGIN_NAME_MISMATCH',
281
+ message: `added as '${elementName}' but ${describeFunctionName}().${nameFieldName} is ${JSON.stringify(describeOutput[nameFieldName])}`,
282
+ };
283
+ }
284
+ if (describeOutput.contractVersion !== contractVersion) {
285
+ return {
286
+ refusalCode: 'PLUGIN_CONTRACT_MISMATCH',
287
+ message: `'${elementName}' declares contractVersion ${JSON.stringify(describeOutput.contractVersion)}; ${libraryName} requires ${JSON.stringify(contractVersion)}`,
288
+ };
289
+ }
290
+ return { describeOutput };
291
+ };
292
+
293
+ const findOverrideDeclarationProblem = (overrideDeclaration) => {
294
+ const { replacesVersion, reason } = overrideDeclaration;
295
+ const hasReplacesVersion = replacesVersion !== undefined && replacesVersion !== null && replacesVersion !== '';
296
+ const hasReason = typeof reason == 'string' && reason;
297
+ return hasReplacesVersion && hasReason ? '' : 'overridesFramework needs both replacesVersion and a reason';
298
+ };
299
+
300
+ const addFromPathList = (elementName, item) => {
301
+ if (!loadContext) {
302
+ // Nothing is loading, so there is no file to attribute this to and no loadModules callback left to report it.
303
+ throw `PLUGIN_ADD_OUTSIDE_LOAD: ${libraryName} library.add('${elementName}') arrived while no module was loading`;
304
+ }
305
+ const { provenance, filePath } = loadContext;
306
+ const refuseHere = (refusalCode, message) => refuse({ refusalCode, elementName, filePath, message: `${message} (${filePath})` });
307
+ loadContext.addedNameList.push(elementName);
308
+
309
+ if (reservedNameList.includes(elementName)) {
310
+ const reservedBy = apiNameList.includes(elementName) ? `a ${libraryName} API name` : 'a member of Object.prototype';
311
+ refuseHere('PLUGIN_NAME_RESERVED', `'${elementName}' is ${reservedBy} and cannot be an element`);
312
+ return;
313
+ }
314
+
315
+ let describeOutput = {};
316
+ if (interfaceSpec) {
317
+ const interfaceResult = checkInterface(elementName, item);
318
+ if (interfaceResult.refusalCode) {
319
+ refuseHere(interfaceResult.refusalCode, interfaceResult.message);
320
+ return;
321
+ }
322
+ describeOutput = interfaceResult.describeOutput;
323
+ }
324
+
325
+ // With an interface the declaration lives in the describe output; without one, on the item itself.
326
+ const overrideDeclaration = interfaceSpec
327
+ ? describeOutput.overridesFramework
328
+ : item !== null && item !== undefined
329
+ ? item.overridesFramework
330
+ : undefined;
331
+ if (overrideDeclaration) {
332
+ const declarationProblem = findOverrideDeclarationProblem(overrideDeclaration);
333
+ if (declarationProblem) {
334
+ refuseHere('PLUGIN_OVERRIDE_DECLARATION_INVALID', `'${elementName}': ${declarationProblem}`);
335
+ return;
336
+ }
337
+ }
338
+
339
+ const priorRecord = provenanceByName[elementName];
340
+ let overrides;
341
+ if (priorRecord) {
342
+ const isDeclaredOverride = priorRecord.provenance == 'framework' && provenance == 'instance' && overrideDeclaration;
343
+ if (!isDeclaredOverride) {
344
+ refuseHere('PLUGIN_NAME_COLLISION', `'${elementName}' is already loaded from ${priorRecord.filePath}; refused from`);
345
+ return;
346
+ }
347
+ const frameworkVersion = declaredVersionByName[elementName];
348
+ const { replacesVersion, reason } = overrideDeclaration;
349
+ if (frameworkVersion === undefined) {
350
+ logList.push(
351
+ `OVERRIDE_TARGET_UNVERSIONED: instance '${elementName}' (${filePath}) replaces framework ${priorRecord.filePath}, which declares no version; replacesVersion ${JSON.stringify(replacesVersion)} cannot be checked`,
352
+ );
353
+ } else if (frameworkVersion !== replacesVersion) {
354
+ logList.push(
355
+ `OVERRIDE_TARGET_CHANGED: instance '${elementName}' (${filePath}) was written against framework version ${JSON.stringify(replacesVersion)}; framework now declares ${JSON.stringify(frameworkVersion)} (${priorRecord.filePath})`,
356
+ );
357
+ }
358
+ overrides = { provenance: priorRecord.provenance, filePath: priorRecord.filePath, version: frameworkVersion, reason };
359
+ } else if (overrideDeclaration) {
360
+ logList.push(`OVERRIDE_TARGET_ABSENT: '${elementName}' (${filePath}) declares overridesFramework but no framework element of that name is loaded`);
361
+ }
362
+
363
+ provenanceByName[elementName] = overrides ? { provenance, filePath, overrides } : { provenance, filePath };
364
+ declaredVersionByName[elementName] = interfaceSpec ? describeOutput[interfaceSpec.versionFieldName] : undefined;
365
+ libraryObject[elementName] = item; //used only for producing display listings
366
+ externalElements[elementName] = item;
367
+ };
368
+
369
+ const formatProvenanceSuffix = (elementName) => {
370
+ const provenanceRecord = provenanceByName[elementName];
371
+ if (libraryPathMode != 'pathList' || !provenanceRecord) {
372
+ return '';
373
+ }
374
+ const { provenance, filePath, overrides } = provenanceRecord;
375
+ const overridesString = overrides
376
+ ? `; overrides framework v${overrides.version === undefined ? '(undeclared)' : overrides.version} (${overrides.filePath}) because ${overrides.reason}`
377
+ : '';
378
+ return ` [${provenance}: ${filePath}${overridesString}]`;
379
+ };
380
+
381
+ const getProvenance = (elementName) => {
382
+ if (libraryPathMode != 'pathList') {
383
+ throw `PROVENANCE_NOT_RECORDED: ${libraryName} records provenance only for libraries loaded with setLibraryPathList`;
384
+ }
385
+ const provenanceRecord = provenanceByName[elementName];
386
+ if (!provenanceRecord) {
387
+ throw `PROVENANCE_UNKNOWN_NAME: ${libraryName} has no element named '${elementName}'`;
388
+ }
389
+ return provenanceRecord.qtClone();
390
+ };
391
+
392
+ const getProvenanceList = () =>
393
+ Object.keys(libraryObject).map((elementName) => ({ elementName, ...getProvenance(elementName) }));
394
+
395
+ const getRefusalList = () => refusalList.map((refusalRecord) => ({ ...refusalRecord }));
396
+
95
397
  // ======================================================================================
96
398
  // LIBRARY OBJECT
97
399
 
@@ -113,7 +415,7 @@ const moduleFunction =
113
415
  (typeof libraryObject[name]).match(/(string|number)/i)
114
416
  ? libraryObject[name]
115
417
  : formatProperty(libraryObject[name])
116
- }\n\t`,
418
+ }${formatProvenanceSuffix(name)}\n\t`,
117
419
  `\n${libraryName} Library Elements\n\t`,
118
420
  )
119
421
  .replace(/\n\t$/, '');
@@ -151,6 +453,11 @@ const moduleFunction =
151
453
  { excludeMode: true },
152
454
  ),
153
455
  );
456
+ if (libraryPathMode == 'pathList') {
457
+ externalElements.qtSelectProperties(['setLibraryPathList', 'setInterface', 'setForbiddenRequireCheck'], {
458
+ excludeMode: true,
459
+ });
460
+ }
154
461
  Object.freeze(externalElements);
155
462
  Object.seal(externalElements);
156
463
  };
@@ -158,7 +465,240 @@ const moduleFunction =
158
465
  // ======================================================================================
159
466
  // LOAD MODULES (actual execution)
160
467
 
161
- const loadModules = ({ passThroughParameters }, callback) => {
468
+ const loadModules = (loadOptions, callback) => {
469
+ if (libraryPathMode == 'pathList') {
470
+ return loadModulesFromPathList(loadOptions, callback);
471
+ }
472
+ if (interfaceSpec || forbiddenRequireSpec) {
473
+ refuse({
474
+ refusalCode: 'REQUIRES_PATH_LIST',
475
+ elementName: '',
476
+ filePath: libraryPath,
477
+ message: `${libraryName} setInterface and setForbiddenRequireCheck apply only to libraries loaded with setLibraryPathList`,
478
+ });
479
+ return deliverResult(refusalList[refusalList.length - 1].message, undefined, callback);
480
+ }
481
+ return loadModulesFromSinglePath(loadOptions, callback);
482
+ };
483
+
484
+ // --------------------------------------------------------------------------------
485
+ // LOAD MODULES FROM A PATH LIST
486
+
487
+ const moduleFilePattern = /\.(c|m)?js$/;
488
+ // Static string-literal requires only. A require built from a variable or a ${} template is not seen.
489
+ const requireCallPattern = /\brequire\s*\(\s*(['"`])([^'"`$]+)\1\s*\)/g;
490
+
491
+ const listModuleSourceFiles = (entryPath, isDirectory) =>
492
+ isDirectory
493
+ ? fs
494
+ .readdirSync(entryPath, { recursive: true })
495
+ .filter((relativePath) => moduleFilePattern.test(relativePath))
496
+ .map((relativePath) => path.join(entryPath, relativePath))
497
+ .filter((sourceFilePath) => fs.statSync(sourceFilePath).isFile())
498
+ : [entryPath];
499
+
500
+ const requirableExtensionList = ['.js', '.cjs', '.mjs', '.json'];
501
+
502
+ const isInsidePath = (candidatePath, containmentRootPath) =>
503
+ candidatePath == containmentRootPath || candidatePath.startsWith(containmentRootPath + path.sep);
504
+
505
+ const isExistingFile = (candidatePath) => fs.existsSync(candidatePath) && fs.statSync(candidatePath).isFile();
506
+
507
+ const readPackageMain = (dirPath) => {
508
+ const packageJsonPath = path.join(dirPath, 'package.json');
509
+ if (!isExistingFile(packageJsonPath)) {
510
+ return '';
511
+ }
512
+ try {
513
+ // Plugin-supplied JSON, like a plugin's require(), can only be guarded here.
514
+ return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).main || '';
515
+ } catch (err) {
516
+ return '';
517
+ }
518
+ };
519
+
520
+ // The file Node would load for a path (exact, with an extension, package.json main, or index), by its realpath.
521
+ // Returns '' when nothing resolves; the caller treats that as forbidden rather than guessing.
522
+ const resolveRequirePath = (requestedPath) => {
523
+ const isDirectory = fs.existsSync(requestedPath) && fs.statSync(requestedPath).isDirectory();
524
+ const packageMain = isDirectory ? readPackageMain(requestedPath) : '';
525
+ const candidatePathList = [
526
+ requestedPath,
527
+ ...requirableExtensionList.map((extension) => `${requestedPath}${extension}`),
528
+ ...(packageMain ? [path.resolve(requestedPath, packageMain), ...requirableExtensionList.map((extension) => `${path.resolve(requestedPath, packageMain)}${extension}`)] : []),
529
+ ...requirableExtensionList.map((extension) => path.join(requestedPath, `index${extension}`)),
530
+ ];
531
+ const resolvedPath = candidatePathList.find(isExistingFile);
532
+ return resolvedPath ? fs.realpathSync(resolvedPath) : '';
533
+ };
534
+
535
+ // Why a path is not acceptable as plugin code inside containmentRootPath, or '' when it is.
536
+ const findPathProblem = (resolvedPath, containmentRootPath) => {
537
+ if (!resolvedPath) {
538
+ return 'does not resolve to a file';
539
+ }
540
+ if (!isInsidePath(resolvedPath, containmentRootPath)) {
541
+ return `resolves outside ${containmentRootPath} (${resolvedPath})`;
542
+ }
543
+ if (!requirableExtensionList.includes(path.extname(resolvedPath))) {
544
+ return `resolves to a non-module file (${resolvedPath})`;
545
+ }
546
+ return '';
547
+ };
548
+
549
+ const findRequireProblem = ({ requiredName, sourceFilePath, containmentRootPath }) => {
550
+ if (requiredName.startsWith('.') || path.isAbsolute(requiredName)) {
551
+ // Node resolves relative to the requiring file's realpath, so a symlinked file is judged where it really is.
552
+ const requestedPath = path.resolve(path.dirname(fs.realpathSync(sourceFilePath)), requiredName);
553
+ return findPathProblem(resolveRequirePath(requestedPath), containmentRootPath);
554
+ }
555
+ const { allowedModuleList } = forbiddenRequireSpec;
556
+ const isAllowed = allowedModuleList.includes(requiredName) || allowedModuleList.includes(requiredName.replace(/^node:/, ''));
557
+ return isAllowed ? '' : 'is not an allowed module';
558
+ };
559
+
560
+ const findForbiddenRequireList = ({ entryPath, isDirectory, rootDirPath }) => {
561
+ // A directory plugin may use only its own directory; a single-file plugin may use its root.
562
+ const containmentRootPath = fs.realpathSync(isDirectory ? entryPath : rootDirPath);
563
+ const entryProblem = findPathProblem(resolveRequirePath(entryPath), containmentRootPath);
564
+ if (entryProblem) {
565
+ return [`the module itself ${entryProblem}`];
566
+ }
567
+ return listModuleSourceFiles(entryPath, isDirectory).flatMap((sourceFilePath) => {
568
+ const sourceFileProblem = findPathProblem(fs.realpathSync(sourceFilePath), containmentRootPath);
569
+ if (sourceFileProblem) {
570
+ return [`${sourceFilePath} ${sourceFileProblem}`];
571
+ }
572
+ return [...fs.readFileSync(sourceFilePath, 'utf8').matchAll(requireCallPattern)]
573
+ .map((requireMatch) => ({ requiredName: requireMatch[2], requireProblem: findRequireProblem({ requiredName: requireMatch[2], sourceFilePath, containmentRootPath }) }))
574
+ .filter(({ requireProblem }) => requireProblem)
575
+ .map(({ requiredName, requireProblem }) => `require('${requiredName}') in ${sourceFilePath} ${requireProblem}`);
576
+ });
577
+ };
578
+
579
+ const loadOneModule = ({ entryPath, provenance, moduleArgs, tolerateLoadErrors }) => {
580
+ loadContext = { provenance, filePath: entryPath, addedNameList: [] };
581
+ let loadErrorString = '';
582
+ try {
583
+ // The one catch in path-list mode: a third-party module's top-level throw can only be caught here.
584
+ require(entryPath)(moduleArgs);
585
+ } catch (err) {
586
+ loadErrorString = err.toString().split('\n').qtFirst('n/a');
587
+ }
588
+ const { addedNameList } = loadContext;
589
+ loadContext = undefined;
590
+
591
+ if (loadErrorString && tolerateLoadErrors) {
592
+ const listedString = `PLUGIN_LOAD_FAILED: ${path.basename(entryPath)}: ${loadErrorString}`;
593
+ exceptionList.push(listedString);
594
+ logList.push(`${listedString} (${entryPath}; listed because tolerateLoadErrors)`);
595
+ return;
596
+ }
597
+ if (loadErrorString) {
598
+ refuse({ refusalCode: 'PLUGIN_LOAD_FAILED', elementName: '', filePath: entryPath, message: `${entryPath}: ${loadErrorString}` });
599
+ return;
600
+ }
601
+ if (!addedNameList.length) {
602
+ logList.push(`MODULE_ADDED_NOTHING: ${provenance} ${entryPath} loaded but called library.add() for nothing`);
603
+ }
604
+ };
605
+
606
+ const loadOneRoot = ({ pathListEntry, moduleArgs, tolerateLoadErrors }) => {
607
+ const { dirPath: rootDirPath, provenance } = pathListEntry;
608
+ const scanForbiddenRequires = forbiddenRequireSpec && forbiddenRequireSpec.provenanceList.includes(provenance);
609
+
610
+ fs.readdirSync(rootDirPath).forEach((entryName) => {
611
+ const entryPath = path.join(rootDirPath, entryName);
612
+ const isDirectory = fs.statSync(entryPath).isDirectory();
613
+ if (entryName.startsWith('.') || (!isDirectory && !moduleFilePattern.test(entryName))) {
614
+ logList.push(`SKIPPED_NON_MODULE: ${provenance} ${entryPath}`);
615
+ return;
616
+ }
617
+ if (scanForbiddenRequires) {
618
+ const forbiddenRequireList = findForbiddenRequireList({ entryPath, isDirectory, rootDirPath });
619
+ if (forbiddenRequireList.length) {
620
+ refuse({
621
+ refusalCode: 'PLUGIN_FORBIDDEN_REQUIRE',
622
+ elementName: '',
623
+ filePath: entryPath,
624
+ message: `${provenance} ${entryPath} may require only allowed modules or its own files; found ${forbiddenRequireList.join(', ')}`,
625
+ });
626
+ return; // never executed
627
+ }
628
+ }
629
+ loadOneModule({ entryPath, provenance, moduleArgs, tolerateLoadErrors });
630
+ });
631
+ };
632
+
633
+ let pathListLoadCount = 0;
634
+
635
+ const loadModulesFromPathList = ({ passThroughParameters, tolerateLoadErrors = false } = {}, callback) => {
636
+ const asynchronousPipePlus = new require('qtools-asynchronous-pipe-plus')();
637
+ const pipeRunner = asynchronousPipePlus.pipeRunner;
638
+ const taskListPlus = asynchronousPipePlus.taskListPlus;
639
+
640
+ const taskList = new taskListPlus();
641
+
642
+ // --------------------------------------------------------------------------------
643
+ // REFUSE A SECOND LOAD (every element would collide with itself)
644
+
645
+ taskList.push((args, next) => {
646
+ if (pathListLoadCount > 0) {
647
+ next(`LOAD_MODULES_REPEATED: ${libraryName} path-list libraries load once; loadModules was already called`, args);
648
+ return;
649
+ }
650
+ pathListLoadCount++;
651
+ next('', args);
652
+ });
653
+
654
+ // --------------------------------------------------------------------------------
655
+ // REFUSE A CUSTOM ADD FUNCTION (it cannot report which file failed)
656
+
657
+ taskList.push((args, next) => {
658
+ if (add !== defaultAdd) {
659
+ next(`CUSTOM_ADD_REQUIRES_SINGLE_PATH: ${libraryName} setCustomAddFunction is not supported with setLibraryPathList`, args);
660
+ return;
661
+ }
662
+ next('', args);
663
+ });
664
+
665
+ // --------------------------------------------------------------------------------
666
+ // LOAD ROOTS BY PROVENANCE: FRAMEWORK FIRST, THEN INSTANCE, WHATEVER THE LIST ORDER
667
+
668
+ taskList.push((args, next) => {
669
+ const moduleArgs = { dotD: { logList, library }, passThroughParameters };
670
+ const orderedEntryList = ['framework', 'instance'].flatMap((provenance) =>
671
+ libraryPathList.filter((pathListEntry) => pathListEntry.provenance == provenance && !pathListEntry.rootIsAbsent),
672
+ );
673
+ orderedEntryList.forEach((pathListEntry) => loadOneRoot({ pathListEntry, moduleArgs, tolerateLoadErrors }));
674
+ next('', args);
675
+ });
676
+
677
+ // --------------------------------------------------------------------------------
678
+ // REPORT EVERY REFUSAL AT ONCE
679
+
680
+ taskList.push((args, next) => {
681
+ if (refusalList.length) {
682
+ next(refusalList.map(({ message }) => message).join('\n'), args);
683
+ return;
684
+ }
685
+ next('', args);
686
+ });
687
+
688
+ // --------------------------------------------------------------------------------
689
+ // INIT AND EXECUTE THE PIPELINE
690
+
691
+ let pipeResult;
692
+ pipeRunner(taskList.getList(), {}, (err) => {
693
+ pipeResult = deliverResult(err, undefined, callback);
694
+ });
695
+ return pipeResult;
696
+ };
697
+
698
+ // --------------------------------------------------------------------------------
699
+ // LOAD MODULES FROM ONE PATH (1.0.13 behaviour, unchanged)
700
+
701
+ const loadModulesFromSinglePath = ({ passThroughParameters }, callback) => {
162
702
  const asynchronousPipePlus = new require(
163
703
  'qtools-asynchronous-pipe-plus',
164
704
  )();
@@ -215,6 +755,12 @@ const moduleFunction =
215
755
 
216
756
  const externalElements = {
217
757
  setLibraryPath,
758
+ setLibraryPathList,
759
+ setInterface,
760
+ setForbiddenRequireCheck,
761
+ getProvenance,
762
+ getProvenanceList,
763
+ getRefusalList,
218
764
  setCustomAddFunction,
219
765
  logList,
220
766
  library,
@@ -223,6 +769,11 @@ const moduleFunction =
223
769
  qtdProcessLog: logList.formatList,
224
770
  };
225
771
  externalElements.seal = sealActual(externalElements);
772
+ // Elements become own properties of this object, so an element may not take an API name, nor a name
773
+ // Object.prototype carries: '__proto__' would replace the prototype, and a qtools-functional-library
774
+ // method such as qtSelectProperties (installed on Object.prototype) would be shadowed, breaking seal().
775
+ apiNameList = Object.keys(externalElements);
776
+ reservedNameList = [...apiNameList, ...Object.getOwnPropertyNames(Object.prototype)];
226
777
  return externalElements;
227
778
  };
228
779