@nitpicker/core 0.4.4 → 0.5.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/lib/core.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export { Nitpicker } from './nitpicker.js';
2
+ export { definePlugin } from './hooks/define-plugin.js';
3
+ export { readPluginLabels } from './read-plugin-labels.js';
4
+ export { runInWorker } from './worker/run-in-worker.js';
5
+ export * from './types.js';
package/lib/core.js ADDED
@@ -0,0 +1,5 @@
1
+ export { Nitpicker } from './nitpicker.js';
2
+ export { definePlugin } from './hooks/define-plugin.js';
3
+ export { readPluginLabels } from './read-plugin-labels.js';
4
+ export { runInWorker } from './worker/run-in-worker.js';
5
+ export * from './types.js';
@@ -1,4 +1,4 @@
1
- import type { Config } from './types.js';
1
+ import type { Config, PluginOverrides } from './types.js';
2
2
  /**
3
3
  * Loads the analyze plugin configuration from the user's config file.
4
4
  *
@@ -13,6 +13,9 @@ import type { Config } from './types.js';
13
13
  * - The config file path is attached to each plugin for relative path resolution
14
14
  * @param defaultConfig - Optional partial config to merge as defaults.
15
15
  * Plugin lists are concatenated (defaults first, then discovered plugins).
16
+ * @param pluginOverrides - CLI-specified plugin setting overrides.
17
+ * Values are shallow-merged into the matching plugin's settings,
18
+ * with CLI values taking precedence over config-file values.
16
19
  * @returns Fully resolved {@link Config} with the `analyze` plugin list.
17
20
  * @example
18
21
  * ```ts
@@ -36,5 +39,6 @@ import type { Config } from './types.js';
36
39
  * ```
37
40
  * @see {@link ./types.ts!Config} for the output type
38
41
  * @see {@link ./types.ts!Plugin} for individual plugin entries
42
+ * @see {@link ./types.ts!PluginOverrides} for CLI override support
39
43
  */
40
- export declare function loadPluginSettings(defaultConfig?: Partial<Config>): Promise<Config>;
44
+ export declare function loadPluginSettings(defaultConfig?: Partial<Config>, pluginOverrides?: PluginOverrides): Promise<Config>;
@@ -21,6 +21,9 @@ const MODULE_NAME = 'nitpicker';
21
21
  * - The config file path is attached to each plugin for relative path resolution
22
22
  * @param defaultConfig - Optional partial config to merge as defaults.
23
23
  * Plugin lists are concatenated (defaults first, then discovered plugins).
24
+ * @param pluginOverrides - CLI-specified plugin setting overrides.
25
+ * Values are shallow-merged into the matching plugin's settings,
26
+ * with CLI values taking precedence over config-file values.
24
27
  * @returns Fully resolved {@link Config} with the `analyze` plugin list.
25
28
  * @example
26
29
  * ```ts
@@ -44,22 +47,25 @@ const MODULE_NAME = 'nitpicker';
44
47
  * ```
45
48
  * @see {@link ./types.ts!Config} for the output type
46
49
  * @see {@link ./types.ts!Plugin} for individual plugin entries
50
+ * @see {@link ./types.ts!PluginOverrides} for CLI override support
47
51
  */
48
- export async function loadPluginSettings(defaultConfig = {}) {
52
+ export async function loadPluginSettings(defaultConfig = {}, pluginOverrides = {}) {
49
53
  const explorer = cosmiconfig(MODULE_NAME);
50
54
  const result = await explorer.search();
51
55
  if (!result) {
52
56
  const defaultPlugins = defaultConfig.analyze || [];
57
+ const plugins = defaultPlugins.length > 0 ? defaultPlugins : discoverAnalyzePlugins();
53
58
  return {
54
- analyze: defaultPlugins.length > 0 ? defaultPlugins : discoverAnalyzePlugins(),
59
+ analyze: applyPluginOverrides(plugins, pluginOverrides),
55
60
  };
56
61
  }
57
62
  const config = result.config;
58
63
  const { isEmpty, filepath } = result;
59
64
  if (!config || isEmpty) {
60
65
  const defaultPlugins = defaultConfig.analyze || [];
66
+ const plugins = defaultPlugins.length > 0 ? defaultPlugins : discoverAnalyzePlugins();
61
67
  return {
62
- analyze: defaultPlugins.length > 0 ? defaultPlugins : discoverAnalyzePlugins(),
68
+ analyze: applyPluginOverrides(plugins, pluginOverrides),
63
69
  };
64
70
  }
65
71
  const analyzePlugins = [];
@@ -79,7 +85,41 @@ export async function loadPluginSettings(defaultConfig = {}) {
79
85
  }
80
86
  }
81
87
  const mergedPlugins = [...(defaultConfig.analyze || []), ...analyzePlugins];
88
+ const finalPlugins = mergedPlugins.length > 0 ? mergedPlugins : discoverAnalyzePlugins();
82
89
  return {
83
- analyze: mergedPlugins.length > 0 ? mergedPlugins : discoverAnalyzePlugins(),
90
+ analyze: applyPluginOverrides(finalPlugins, pluginOverrides),
84
91
  };
85
92
  }
93
+ /**
94
+ * Applies CLI-specified overrides to plugin settings.
95
+ *
96
+ * For each plugin in the list, if there is a matching entry in
97
+ * `overrides`, the override values are shallow-merged into the
98
+ * plugin's existing settings. CLI values take precedence over
99
+ * config-file values.
100
+ *
101
+ * **Limitation**: Merging is shallow (one level). Nested objects in
102
+ * the override will replace the entire config-file value, not deep-merge.
103
+ * @param plugins - The plugin list to apply overrides to.
104
+ * @param overrides - CLI-specified plugin setting overrides.
105
+ * @returns A new plugin array with overrides applied.
106
+ */
107
+ function applyPluginOverrides(plugins, overrides) {
108
+ const overrideKeys = Object.keys(overrides);
109
+ if (overrideKeys.length === 0) {
110
+ return plugins;
111
+ }
112
+ return plugins.map((plugin) => {
113
+ const override = overrides[plugin.module];
114
+ if (!override) {
115
+ return plugin;
116
+ }
117
+ return {
118
+ ...plugin,
119
+ settings: {
120
+ ...plugin.settings,
121
+ ...override,
122
+ },
123
+ };
124
+ });
125
+ }
@@ -1,4 +1,4 @@
1
- import type { AnalyzeOptions, Config, NitpickerEvent } from './types.js';
1
+ import type { AnalyzeOptions, Config, NitpickerEvent, PluginOverrides } from './types.js';
2
2
  import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-await-event-emitter';
3
3
  import { Archive } from '@nitpicker/crawler';
4
4
  export { definePlugin } from './hooks/define-plugin.js';
@@ -101,6 +101,16 @@ export declare class Nitpicker extends EventEmitter<NitpickerEvent> {
101
101
  * @returns Resolved {@link Config} with the `analyze` plugin list.
102
102
  */
103
103
  getConfig(): Promise<Config>;
104
+ /**
105
+ * Sets CLI-specified plugin overrides that take precedence over
106
+ * config-file settings.
107
+ *
108
+ * Must be called before {@link getConfig} to take effect. If config
109
+ * has already been loaded, calling this method clears the cache so
110
+ * that the next `getConfig()` call re-loads with the new overrides.
111
+ * @param overrides - Plugin setting overrides from CLI flags.
112
+ */
113
+ setPluginOverrides(overrides: PluginOverrides): void;
104
114
  /**
105
115
  * Writes the archive (including any new analysis results) to disk
106
116
  * as a `.nitpicker` tar file, then emits a `writeFile` event.
package/lib/nitpicker.js CHANGED
@@ -73,6 +73,11 @@ export class Nitpicker extends EventEmitter {
73
73
  * `null` until `getConfig()` is first called.
74
74
  */
75
75
  #config = null;
76
+ /**
77
+ * CLI-specified plugin setting overrides.
78
+ * Passed to `loadPluginSettings()` to merge with config-file settings.
79
+ */
80
+ #pluginOverrides = {};
76
81
  /** The underlying archive instance. */
77
82
  get archive() {
78
83
  return this.#archive;
@@ -179,57 +184,77 @@ export class Nitpicker extends EventEmitter {
179
184
  updateProgress();
180
185
  // Bounded Promise pool (replaces deal())
181
186
  const executing = new Set();
187
+ let pluginPageDataCount = 0;
188
+ let pluginErrorCount = 0;
182
189
  for (const [pageIndex, page] of pages.entries()) {
183
190
  const task = (async () => {
184
- const cacheKey = `${plugin.name}:${page.url.href}`;
185
- const cached = await cache.load(cacheKey);
186
- if (cached) {
187
- const { pages: cachedPages, violations } = cached;
188
- if (cachedPages) {
189
- table.addData(cachedPages);
191
+ try {
192
+ const cacheKey = `${plugin.name}:${page.url.href}`;
193
+ const cached = await cache.load(cacheKey);
194
+ if (cached) {
195
+ const { pages: cachedPages, violations } = cached;
196
+ if (cachedPages) {
197
+ table.addData(cachedPages);
198
+ pluginPageDataCount += Object.keys(cachedPages).length;
199
+ }
200
+ if (violations) {
201
+ for (const v of violations) {
202
+ allViolations.push(v);
203
+ }
204
+ pluginViolationCount += violations.length;
205
+ }
206
+ done++;
207
+ updateProgress();
208
+ return;
209
+ }
210
+ const html = await page.getHtml();
211
+ if (!html) {
212
+ done++;
213
+ updateProgress();
214
+ return;
190
215
  }
191
- if (violations) {
192
- allViolations.push(...violations);
193
- pluginViolationCount += violations.length;
216
+ const report = await runInWorker({
217
+ filePath: pageAnalysisWorkerPath,
218
+ num: pageIndex,
219
+ total: pages.length,
220
+ emitter: urlEmitter,
221
+ initialData: {
222
+ plugin,
223
+ pages: {
224
+ html,
225
+ url: page.url,
226
+ },
227
+ },
228
+ });
229
+ const tablePages = {};
230
+ if (report?.page) {
231
+ tablePages[page.url.href] = report.page;
232
+ table.addDataToUrl(page.url, report.page);
233
+ pluginPageDataCount++;
234
+ }
235
+ await cache.store(cacheKey, {
236
+ pages: Object.keys(tablePages).length > 0 ? tablePages : undefined,
237
+ violations: report?.violations,
238
+ });
239
+ if (report?.violations) {
240
+ for (const v of report.violations) {
241
+ allViolations.push(v);
242
+ }
243
+ pluginViolationCount += report.violations.length;
194
244
  }
195
245
  done++;
196
246
  updateProgress();
197
- return;
198
247
  }
199
- const html = await page.getHtml();
200
- if (!html) {
248
+ catch (error) {
249
+ pluginErrorCount++;
201
250
  done++;
202
251
  updateProgress();
203
- return;
252
+ const message = error instanceof Error ? error.message : String(error);
253
+ await this.emit('error', {
254
+ message: `[${plugin.name}] Failed to analyze ${page.url.href}: ${message}`,
255
+ error: error instanceof Error ? error : null,
256
+ });
204
257
  }
205
- const report = await runInWorker({
206
- filePath: pageAnalysisWorkerPath,
207
- num: pageIndex,
208
- total: pages.length,
209
- emitter: urlEmitter,
210
- initialData: {
211
- plugin,
212
- pages: {
213
- html,
214
- url: page.url,
215
- },
216
- },
217
- });
218
- const tablePages = {};
219
- if (report?.page) {
220
- tablePages[page.url.href] = report.page;
221
- table.addDataToUrl(page.url, report.page);
222
- }
223
- await cache.store(cacheKey, {
224
- pages: Object.keys(tablePages).length > 0 ? tablePages : undefined,
225
- violations: report?.violations,
226
- });
227
- if (report?.violations) {
228
- allViolations.push(...report.violations);
229
- pluginViolationCount += report.violations.length;
230
- }
231
- done++;
232
- updateProgress();
233
258
  })();
234
259
  executing.add(task);
235
260
  task.then(() => executing.delete(task), () => executing.delete(task));
@@ -238,6 +263,14 @@ export class Nitpicker extends EventEmitter {
238
263
  }
239
264
  }
240
265
  await Promise.all(executing);
266
+ // Warn when plugin produced no page data at all
267
+ if (pluginPageDataCount === 0 && pages.length > 0) {
268
+ const errorDetail = pluginErrorCount > 0 ? ` (${pluginErrorCount} errors occurred)` : '';
269
+ await this.emit('error', {
270
+ message: `[${plugin.name}] Produced no data for ${pages.length} pages${errorDetail}. Check plugin configuration and HTML snapshots.`,
271
+ error: null,
272
+ });
273
+ }
241
274
  // Mark this plugin as Done
242
275
  const detail = pluginViolationCount > 0
243
276
  ? `${pluginViolationCount} violations`
@@ -267,16 +300,27 @@ export class Nitpicker extends EventEmitter {
267
300
  if (!mod.eachUrl) {
268
301
  continue;
269
302
  }
270
- const report = await mod.eachUrl({ url, isExternal });
271
- if (!report) {
272
- continue;
273
- }
274
- const { page: reportPage, violations } = report;
275
- if (reportPage) {
276
- table.addDataToUrl(url, reportPage);
303
+ try {
304
+ const report = await mod.eachUrl({ url, isExternal });
305
+ if (!report) {
306
+ continue;
307
+ }
308
+ const { page: reportPage, violations } = report;
309
+ if (reportPage) {
310
+ table.addDataToUrl(url, reportPage);
311
+ }
312
+ if (violations) {
313
+ for (const v of violations) {
314
+ allViolations.push(v);
315
+ }
316
+ }
277
317
  }
278
- if (violations) {
279
- allViolations.push(...violations);
318
+ catch (error) {
319
+ const message = error instanceof Error ? error.message : String(error);
320
+ await this.emit('error', {
321
+ message: `[eachUrl] Failed to analyze ${url.href}: ${message}`,
322
+ error: error instanceof Error ? error : null,
323
+ });
280
324
  }
281
325
  }
282
326
  }
@@ -302,10 +346,23 @@ export class Nitpicker extends EventEmitter {
302
346
  */
303
347
  async getConfig() {
304
348
  if (!this.#config) {
305
- this.#config = await loadPluginSettings();
349
+ this.#config = await loadPluginSettings({}, this.#pluginOverrides);
306
350
  }
307
351
  return this.#config;
308
352
  }
353
+ /**
354
+ * Sets CLI-specified plugin overrides that take precedence over
355
+ * config-file settings.
356
+ *
357
+ * Must be called before {@link getConfig} to take effect. If config
358
+ * has already been loaded, calling this method clears the cache so
359
+ * that the next `getConfig()` call re-loads with the new overrides.
360
+ * @param overrides - Plugin setting overrides from CLI flags.
361
+ */
362
+ setPluginOverrides(overrides) {
363
+ this.#pluginOverrides = overrides;
364
+ this.#config = null;
365
+ }
309
366
  /**
310
367
  * Writes the archive (including any new analysis results) to disk
311
368
  * as a `.nitpicker` tar file, then emits a `writeFile` event.
@@ -85,9 +85,8 @@ export default async function (data, urlEventBus, num, total) {
85
85
  return report ?? null;
86
86
  }
87
87
  catch (error) {
88
- // eslint-disable-next-line no-console
89
- console.error(`[${plugin.name}] ${error instanceof Error ? error.message : error}`);
90
- return null;
88
+ const message = error instanceof Error ? error.message : String(error);
89
+ throw new Error(`[${plugin.name}] ${message}`);
91
90
  }
92
91
  finally {
93
92
  for (const key of domGlobalKeys) {
package/lib/types.d.ts CHANGED
@@ -45,6 +45,42 @@ export interface AnalyzeOptions {
45
45
  /** When `true`, outputs plain-text progress lines instead of animated Lanes. */
46
46
  readonly verbose?: boolean;
47
47
  }
48
+ /**
49
+ * CLI-specified overrides for individual plugin settings.
50
+ *
51
+ * These values are provided via CLI flags (e.g. `--search-keywords`,
52
+ * `--axe-lang`) and take precedence over settings loaded from the
53
+ * configuration file. Each key maps to a known `@nitpicker/analyze-*`
54
+ * plugin module name, and the value is a partial settings object that
55
+ * will be shallow-merged into the plugin's config-file settings.
56
+ *
57
+ * ## Adding CLI overrides for a new plugin
58
+ *
59
+ * 1. Add a property to this interface with the plugin module name as key
60
+ * 2. Add corresponding CLI flag(s) to `commandDef.flags` in `@nitpicker/cli/src/commands/analyze.ts`
61
+ * 3. Add flag-to-override mapping in `@nitpicker/cli/src/analyze/build-plugin-overrides.ts`
62
+ * 4. Update `README.md` analyze options table
63
+ * @see {@link ./load-plugin-settings.ts!loadPluginSettings} for the merge logic
64
+ */
65
+ export interface PluginOverrides {
66
+ /** Overrides for `@nitpicker/analyze-search` plugin settings. */
67
+ '@nitpicker/analyze-search'?: {
68
+ /** Keywords to search for in page content. */
69
+ keywords?: string[];
70
+ /** CSS selector to narrow the search scope. */
71
+ scope?: string;
72
+ };
73
+ /** Overrides for `@nitpicker/analyze-main-contents` plugin settings. */
74
+ '@nitpicker/analyze-main-contents'?: {
75
+ /** CSS selector for main content detection. */
76
+ mainContentSelector?: string;
77
+ };
78
+ /** Overrides for `@nitpicker/analyze-axe` plugin settings. */
79
+ '@nitpicker/analyze-axe'?: {
80
+ /** BCP 47 language tag for locale. */
81
+ lang?: string;
82
+ };
83
+ }
48
84
  /**
49
85
  * Internal configuration model used by {@link ../nitpicker.ts!Nitpicker}.
50
86
  *
@@ -106,7 +106,12 @@ export function runInWorker(params) {
106
106
  process.removeListener('uncaughtException', killWorker);
107
107
  process.removeListener('uncaughtExceptionMonitor', killWorker);
108
108
  process.removeListener('unhandledRejection', killWorker);
109
- resolve(message.result);
109
+ if (message.error) {
110
+ reject(new Error(message.error));
111
+ }
112
+ else {
113
+ resolve(message.result);
114
+ }
110
115
  }
111
116
  });
112
117
  });
@@ -43,11 +43,26 @@ emitter.on('url', (url) => {
43
43
  url,
44
44
  });
45
45
  });
46
- const result = await runner(data, emitter);
47
- if (!parentPort) {
48
- throw new Error('Use in worker thread');
46
+ try {
47
+ const result = await runner(data, emitter);
48
+ if (!parentPort) {
49
+ throw new Error('Use in worker thread');
50
+ }
51
+ parentPort.postMessage({
52
+ type: 'finish',
53
+ result,
54
+ });
55
+ }
56
+ catch (error) {
57
+ if (!parentPort) {
58
+ throw new Error('Use in worker thread');
59
+ }
60
+ const message = error instanceof Error ? error.message : String(error);
61
+ // eslint-disable-next-line no-console
62
+ console.error(`[worker] Fatal error: ${message}`);
63
+ parentPort.postMessage({
64
+ type: 'finish',
65
+ result: null,
66
+ error: message,
67
+ });
49
68
  }
50
- parentPort.postMessage({
51
- type: 'finish',
52
- result,
53
- });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitpicker/core",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
4
4
  "description": "Plugin-based page analysis engine for Nitpicker",
5
5
  "author": "D-ZERO",
6
6
  "license": "Apache-2.0",
@@ -18,8 +18,8 @@
18
18
  "type": "module",
19
19
  "exports": {
20
20
  ".": {
21
- "import": "./lib/index.js",
22
- "types": "./lib/index.d.ts"
21
+ "import": "./lib/core.js",
22
+ "types": "./lib/core.d.ts"
23
23
  }
24
24
  },
25
25
  "scripts": {
@@ -27,13 +27,13 @@
27
27
  "clean": "tsc --build --clean"
28
28
  },
29
29
  "dependencies": {
30
- "@d-zero/dealer": "1.6.3",
31
- "@d-zero/shared": "0.20.0",
32
- "@nitpicker/crawler": "0.4.4",
33
- "@nitpicker/types": "0.4.4",
30
+ "@d-zero/dealer": "1.7.0",
31
+ "@d-zero/shared": "0.20.1",
32
+ "@nitpicker/crawler": "0.5.0",
33
+ "@nitpicker/types": "0.5.0",
34
34
  "ansi-colors": "4.1.3",
35
35
  "cosmiconfig": "9.0.0",
36
36
  "jsdom": "28.1.0"
37
37
  },
38
- "gitHead": "7c9bad351d1a3f4eb6ea7233f7143140d0989ef2"
38
+ "gitHead": "607d06bd596a0270d7088f32373d7367eb47ea94"
39
39
  }