From f063e0a474755b122c025f592e8c91715089ffd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Mar 2026 03:49:34 +0000 Subject: [PATCH 1/4] feat: add query CLI command for querying .nitpicker archives Exposes @nitpicker/query functions via CLI sub-commands (summary, pages, page-detail, html, links, resources, images, violations, duplicates, mismatches, headers, resource-referrers). Outputs JSON to stdout. https://claude.ai/code/session_01RgcxfZHjSbBBcT7df5BymS --- packages/@nitpicker/cli/package.json | 1 + packages/@nitpicker/cli/src/cli.ts | 6 + .../@nitpicker/cli/src/commands/query.spec.ts | 126 +++++++++++ packages/@nitpicker/cli/src/commands/query.ts | 185 ++++++++++++++++ .../cli/src/query/dispatch-query.spec.ts | 148 +++++++++++++ .../cli/src/query/dispatch-query.ts | 117 ++++++++++ .../query/map-flags-to-query-options.spec.ts | 196 +++++++++++++++++ .../src/query/map-flags-to-query-options.ts | 203 ++++++++++++++++++ packages/@nitpicker/cli/src/query/types.ts | 34 +++ packages/@nitpicker/cli/tsconfig.json | 1 + yarn.lock | 1 + 11 files changed, 1018 insertions(+) create mode 100644 packages/@nitpicker/cli/src/commands/query.spec.ts create mode 100644 packages/@nitpicker/cli/src/commands/query.ts create mode 100644 packages/@nitpicker/cli/src/query/dispatch-query.spec.ts create mode 100644 packages/@nitpicker/cli/src/query/dispatch-query.ts create mode 100644 packages/@nitpicker/cli/src/query/map-flags-to-query-options.spec.ts create mode 100644 packages/@nitpicker/cli/src/query/map-flags-to-query-options.ts create mode 100644 packages/@nitpicker/cli/src/query/types.ts diff --git a/packages/@nitpicker/cli/package.json b/packages/@nitpicker/cli/package.json index 5903b7b..39012d7 100644 --- a/packages/@nitpicker/cli/package.json +++ b/packages/@nitpicker/cli/package.json @@ -43,6 +43,7 @@ "@nitpicker/analyze-textlint": "0.4.4", "@nitpicker/core": "0.4.4", "@nitpicker/crawler": "0.4.4", + "@nitpicker/query": "0.4.4", "@nitpicker/report-google-sheets": "0.4.4", "ansi-colors": "4.1.3", "debug": "4.4.3", diff --git a/packages/@nitpicker/cli/src/cli.ts b/packages/@nitpicker/cli/src/cli.ts index 9e7585c..cd47b36 100644 --- a/packages/@nitpicker/cli/src/cli.ts +++ b/packages/@nitpicker/cli/src/cli.ts @@ -3,6 +3,7 @@ import { parseCli } from '@d-zero/roar'; import { analyze, commandDef as analyzeDef } from './commands/analyze.js'; import { crawl, commandDef as crawlDef } from './commands/crawl.js'; import { pipeline, commandDef as pipelineDef } from './commands/pipeline.js'; +import { query, commandDef as queryDef } from './commands/query.js'; import { report, commandDef as reportDef } from './commands/report.js'; import { ExitCode } from './exit-code.js'; import { formatCliError } from './format-cli-error.js'; @@ -16,6 +17,7 @@ const cli = parseCli({ analyze: analyzeDef, report: reportDef, pipeline: pipelineDef, + query: queryDef, }, onError: () => true, }); @@ -38,6 +40,10 @@ try { await pipeline(cli.args, cli.flags); break; } + case 'query': { + await query(cli.args, cli.flags); + break; + } } } catch (error) { formatCliError(error, true); diff --git a/packages/@nitpicker/cli/src/commands/query.spec.ts b/packages/@nitpicker/cli/src/commands/query.spec.ts new file mode 100644 index 0000000..151eba2 --- /dev/null +++ b/packages/@nitpicker/cli/src/commands/query.spec.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; + +import { formatCliError as formatCliErrorFn } from '../format-cli-error.js'; +import { dispatchQuery as dispatchQueryFn } from '../query/dispatch-query.js'; + +import { query } from './query.js'; + +vi.mock('@nitpicker/query', () => ({ + ArchiveManager: vi.fn().mockImplementation(function (this: { + open: ReturnType; + close: ReturnType; + }) { + this.open = vi.fn().mockResolvedValue({ + archiveId: 'archive_1', + accessor: {}, + }); + this.close = vi.fn().mockResolvedValue(); + }), +})); + +vi.mock('../query/dispatch-query.js', () => ({ + dispatchQuery: vi + .fn() + .mockResolvedValue({ baseUrl: 'https://example.com', totalPages: 5 }), +})); + +vi.mock('../format-cli-error.js', () => ({ + formatCliError: vi.fn(), +})); + +/** Sentinel error thrown by the process.exit mock to halt execution. */ +class ExitError extends Error { + /** The exit code passed to process.exit(). */ + readonly code: number; + constructor(code: number) { + super(`process.exit(${code})`); + this.code = code; + } +} + +describe('query command', () => { + let exitSpy: ReturnType; + let consoleErrorSpy: ReturnType; + let consoleLogSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new ExitError(code as number); + }); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('exits with error when no file path is provided', async () => { + await expect(query([], {} as never)).rejects.toThrow(ExitError); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error: No .nitpicker file specified.'); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Usage: nitpicker query [options]', + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('exits with error when no sub-command is provided', async () => { + await expect(query(['test.nitpicker'], {} as never)).rejects.toThrow(ExitError); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error: No sub-command specified.'); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Valid sub-commands:'), + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('exits with error for unknown sub-command', async () => { + await expect(query(['test.nitpicker', 'unknown'], {} as never)).rejects.toThrow( + ExitError, + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error: Unknown sub-command: unknown'); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('outputs JSON result to stdout on success', async () => { + await query(['test.nitpicker', 'summary'], { pretty: undefined } as never); + + expect(dispatchQueryFn).toHaveBeenCalled(); + expect(consoleLogSpy).toHaveBeenCalledWith( + JSON.stringify({ baseUrl: 'https://example.com', totalPages: 5 }), + ); + }); + + it('pretty-prints when --pretty is set', async () => { + await query(['test.nitpicker', 'summary'], { pretty: true } as never); + + expect(consoleLogSpy).toHaveBeenCalledWith( + JSON.stringify({ baseUrl: 'https://example.com', totalPages: 5 }, null, 2), + ); + }); + + it('closes archive after successful query', async () => { + const { ArchiveManager } = await import('@nitpicker/query'); + await query(['test.nitpicker', 'summary'], { pretty: undefined } as never); + + const managerInstance = vi.mocked(ArchiveManager).mock.results[0]?.value; + expect(managerInstance.close).toHaveBeenCalledWith('archive_1'); + }); + + it('closes archive even when dispatch throws', async () => { + vi.mocked(dispatchQueryFn).mockRejectedValueOnce(new Error('Query failed')); + const { ArchiveManager } = await import('@nitpicker/query'); + + await expect( + query(['test.nitpicker', 'summary'], { pretty: undefined } as never), + ).rejects.toThrow(ExitError); + + const managerInstance = vi.mocked(ArchiveManager).mock.results[0]?.value; + expect(managerInstance.close).toHaveBeenCalledWith('archive_1'); + expect(formatCliErrorFn).toHaveBeenCalledWith(expect.any(Error), false); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/@nitpicker/cli/src/commands/query.ts b/packages/@nitpicker/cli/src/commands/query.ts new file mode 100644 index 0000000..9fad9d1 --- /dev/null +++ b/packages/@nitpicker/cli/src/commands/query.ts @@ -0,0 +1,185 @@ +import type { QuerySubCommand } from '../query/types.js'; +import type { CommandDef, InferFlags } from '@d-zero/roar'; + +import path from 'node:path'; + +import { ArchiveManager } from '@nitpicker/query'; + +import { formatCliError } from '../format-cli-error.js'; +import { dispatchQuery } from '../query/dispatch-query.js'; +import { VALID_SUB_COMMANDS } from '../query/types.js'; + +/** + * Command definition for the `query` sub-command. + * @see {@link query} for the main entry point + */ +export const commandDef = { + desc: 'Query a .nitpicker archive', + flags: { + limit: { + type: 'number', + shortFlag: 'l', + desc: 'Maximum number of results to return', + }, + offset: { + type: 'number', + shortFlag: 'o', + desc: 'Number of results to skip', + }, + url: { + type: 'string', + desc: 'Target URL for page-detail, html, or resource-referrers queries', + }, + status: { + type: 'number', + desc: 'Filter by exact HTTP status code', + }, + statusMin: { + type: 'number', + desc: 'Filter by minimum HTTP status code (inclusive)', + }, + statusMax: { + type: 'number', + desc: 'Filter by maximum HTTP status code (inclusive)', + }, + isExternal: { + type: 'boolean', + desc: 'Filter by external (true) or internal (false)', + }, + missingTitle: { + type: 'boolean', + desc: 'Filter to pages missing title', + }, + missingDescription: { + type: 'boolean', + desc: 'Filter to pages missing description', + }, + noindex: { + type: 'boolean', + desc: 'Filter to pages with noindex', + }, + urlPattern: { + type: 'string', + desc: 'URL pattern to filter (SQL LIKE pattern)', + }, + directory: { + type: 'string', + desc: 'Directory path prefix to filter by', + }, + sortBy: { + type: 'string', + desc: 'Field to sort by (url, status, title)', + }, + sortOrder: { + type: 'string', + desc: 'Sort direction (asc, desc)', + }, + type: { + type: 'string', + desc: 'Filter type: broken, external, orphaned (links) or canonical, og:title, og:description (mismatches)', + }, + contentType: { + type: 'string', + desc: 'Filter by content type prefix (e.g. text/css)', + }, + missingAlt: { + type: 'boolean', + desc: 'Filter to images missing alt attribute', + }, + missingDimensions: { + type: 'boolean', + desc: 'Filter to images missing width/height', + }, + oversizedThreshold: { + type: 'number', + desc: 'Filter to images exceeding this dimension threshold', + }, + validator: { + type: 'string', + desc: 'Filter by validator name (e.g. axe, markuplint)', + }, + severity: { + type: 'string', + desc: 'Filter by severity level', + }, + rule: { + type: 'string', + desc: 'Filter by rule ID', + }, + field: { + type: 'string', + desc: 'Field to check for duplicates (title, description)', + }, + missingOnly: { + type: 'boolean', + desc: 'Only show pages missing security headers', + }, + maxLength: { + type: 'number', + desc: 'Maximum HTML length to return', + }, + pretty: { + type: 'boolean', + desc: 'Pretty-print JSON output', + }, + }, +} as const satisfies CommandDef; + +/** Parsed flag values for the `query` CLI command. */ +type QueryFlags = InferFlags; + +/** + * Main entry point for the `query` CLI command. + * + * Opens a `.nitpicker` archive, dispatches the specified sub-command + * to the appropriate `@nitpicker/query` function, and prints the result + * as JSON to stdout. + * @param args - Positional arguments; first is the `.nitpicker` file path, second is the sub-command. + * @param flags - Parsed CLI flags from the `query` command. + * @returns Resolves when the query is complete. + * Exits with code 1 if arguments are missing/invalid or an error occurs. + */ +export async function query(args: string[], flags: QueryFlags) { + const filePath = args[0]; + if (!filePath) { + // eslint-disable-next-line no-console + console.error('Error: No .nitpicker file specified.'); + // eslint-disable-next-line no-console + console.error('Usage: nitpicker query [options]'); + process.exit(1); + } + + const subCommandArg = args[1]; + if (!subCommandArg || !VALID_SUB_COMMANDS.includes(subCommandArg as QuerySubCommand)) { + // eslint-disable-next-line no-console + console.error( + subCommandArg + ? `Error: Unknown sub-command: ${subCommandArg}` + : 'Error: No sub-command specified.', + ); + // eslint-disable-next-line no-console + console.error(`Valid sub-commands: ${VALID_SUB_COMMANDS.join(', ')}`); + process.exit(1); + } + + const subCommand = subCommandArg as QuerySubCommand; + const absFilePath = path.isAbsolute(filePath) + ? filePath + : path.resolve(process.cwd(), filePath); + + const manager = new ArchiveManager(); + try { + const { archiveId, accessor } = await manager.open(absFilePath); + try { + const result = await dispatchQuery(accessor, subCommand, flags); + const output = JSON.stringify(result, null, flags.pretty ? 2 : undefined); + // eslint-disable-next-line no-console + console.log(output); + } finally { + await manager.close(archiveId); + } + } catch (error) { + formatCliError(error, false); + process.exit(1); + } +} diff --git a/packages/@nitpicker/cli/src/query/dispatch-query.spec.ts b/packages/@nitpicker/cli/src/query/dispatch-query.spec.ts new file mode 100644 index 0000000..1720a46 --- /dev/null +++ b/packages/@nitpicker/cli/src/query/dispatch-query.spec.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; + +import { dispatchQuery } from './dispatch-query.js'; + +vi.mock('@nitpicker/query', () => ({ + getSummary: vi + .fn() + .mockResolvedValue({ baseUrl: 'https://example.com', totalPages: 10 }), + listPages: vi.fn().mockResolvedValue({ items: [], total: 0, offset: 0, limit: 100 }), + getPageDetail: vi.fn().mockResolvedValue({ url: 'https://example.com', status: 200 }), + getPageHtml: vi.fn().mockResolvedValue({ html: '', truncated: false }), + listLinks: vi.fn().mockResolvedValue({ items: [], total: 0 }), + listResources: vi + .fn() + .mockResolvedValue({ items: [], total: 0, offset: 0, limit: 100 }), + listImages: vi.fn().mockResolvedValue({ items: [], total: 0, offset: 0, limit: 100 }), + getViolations: vi.fn().mockResolvedValue({ items: [], total: 0 }), + findDuplicates: vi.fn().mockResolvedValue([]), + findMismatches: vi.fn().mockResolvedValue([]), + checkHeaders: vi.fn().mockResolvedValue({ items: [], total: 0, offset: 0, limit: 100 }), + getResourceReferrers: vi + .fn() + .mockResolvedValue({ + resourceUrl: 'https://example.com/style.css', + pageUrls: [], + total: 0, + }), + ArchiveManager: vi.fn(), +})); + +/** Mock accessor for testing dispatch calls. */ +const mockAccessor = {} as never; + +/** Default empty flags for testing. */ +const emptyFlags = {} as never; + +describe('dispatchQuery', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('dispatches summary sub-command', async () => { + const result = await dispatchQuery(mockAccessor, 'summary', emptyFlags); + expect(result).toEqual({ baseUrl: 'https://example.com', totalPages: 10 }); + }); + + it('dispatches pages sub-command', async () => { + const result = await dispatchQuery(mockAccessor, 'pages', emptyFlags); + expect(result).toEqual({ items: [], total: 0, offset: 0, limit: 100 }); + }); + + it('dispatches page-detail sub-command', async () => { + const result = await dispatchQuery(mockAccessor, 'page-detail', { + url: 'https://example.com', + } as never); + expect(result).toEqual({ url: 'https://example.com', status: 200 }); + }); + + it('throws when page-detail returns null', async () => { + const { getPageDetail } = await import('@nitpicker/query'); + vi.mocked(getPageDetail).mockResolvedValueOnce(null); + + await expect( + dispatchQuery(mockAccessor, 'page-detail', { url: 'https://missing.com' } as never), + ).rejects.toThrow('Page not found: https://missing.com'); + }); + + it('dispatches html sub-command', async () => { + const result = await dispatchQuery(mockAccessor, 'html', { + url: 'https://example.com', + } as never); + expect(result).toEqual({ html: '', truncated: false }); + }); + + it('throws when html returns null', async () => { + const { getPageHtml } = await import('@nitpicker/query'); + vi.mocked(getPageHtml).mockResolvedValueOnce(null); + + await expect( + dispatchQuery(mockAccessor, 'html', { url: 'https://missing.com' } as never), + ).rejects.toThrow('Page HTML not found: https://missing.com'); + }); + + it('dispatches links sub-command', async () => { + const result = await dispatchQuery(mockAccessor, 'links', { + type: 'broken', + } as never); + expect(result).toEqual({ items: [], total: 0 }); + }); + + it('dispatches resources sub-command', async () => { + const result = await dispatchQuery(mockAccessor, 'resources', emptyFlags); + expect(result).toEqual({ items: [], total: 0, offset: 0, limit: 100 }); + }); + + it('dispatches images sub-command', async () => { + const result = await dispatchQuery(mockAccessor, 'images', emptyFlags); + expect(result).toEqual({ items: [], total: 0, offset: 0, limit: 100 }); + }); + + it('dispatches violations sub-command', async () => { + const result = await dispatchQuery(mockAccessor, 'violations', emptyFlags); + expect(result).toEqual({ items: [], total: 0 }); + }); + + it('dispatches duplicates sub-command', async () => { + const result = await dispatchQuery(mockAccessor, 'duplicates', emptyFlags); + expect(result).toEqual([]); + }); + + it('dispatches mismatches sub-command', async () => { + const result = await dispatchQuery(mockAccessor, 'mismatches', { + type: 'canonical', + } as never); + expect(result).toEqual([]); + }); + + it('dispatches headers sub-command', async () => { + const result = await dispatchQuery(mockAccessor, 'headers', emptyFlags); + expect(result).toEqual({ items: [], total: 0, offset: 0, limit: 100 }); + }); + + it('dispatches resource-referrers sub-command', async () => { + const result = await dispatchQuery(mockAccessor, 'resource-referrers', { + url: 'https://example.com/style.css', + } as never); + expect(result).toEqual({ + resourceUrl: 'https://example.com/style.css', + pageUrls: [], + total: 0, + }); + }); + + it('throws when resource-referrers returns null', async () => { + const { getResourceReferrers } = await import('@nitpicker/query'); + vi.mocked(getResourceReferrers).mockResolvedValueOnce(null); + + await expect( + dispatchQuery(mockAccessor, 'resource-referrers', { + url: 'https://missing.com/style.css', + } as never), + ).rejects.toThrow('Resource not found: https://missing.com/style.css'); + }); +}); diff --git a/packages/@nitpicker/cli/src/query/dispatch-query.ts b/packages/@nitpicker/cli/src/query/dispatch-query.ts new file mode 100644 index 0000000..d2c69b4 --- /dev/null +++ b/packages/@nitpicker/cli/src/query/dispatch-query.ts @@ -0,0 +1,117 @@ +import type { QuerySubCommand } from './types.js'; +import type { commandDef } from '../commands/query.js'; +import type { InferFlags } from '@d-zero/roar'; +import type { ArchiveAccessor } from '@nitpicker/crawler'; +import type { + ListPagesOptions, + ListLinksOptions, + ListResourcesOptions, + ListImagesOptions, + GetViolationsOptions, +} from '@nitpicker/query'; + +import { + getSummary, + listPages, + getPageDetail, + getPageHtml, + listLinks, + listResources, + listImages, + getViolations, + findDuplicates, + findMismatches, + checkHeaders, + getResourceReferrers, +} from '@nitpicker/query'; + +import { mapFlagsToQueryOptions } from './map-flags-to-query-options.js'; + +/** Parsed flag values for the query CLI command. */ +type QueryFlags = InferFlags; + +/** + * Dispatches a query sub-command to the appropriate `@nitpicker/query` function. + * + * Maps each sub-command name to its corresponding query function, builds the + * options from CLI flags via {@link mapFlagsToQueryOptions}, and returns the + * JSON-serializable result. + * @param accessor - The opened archive accessor. + * @param subCommand - The sub-command name. + * @param flags - The parsed CLI flags. + * @returns The query result as a JSON-serializable value. + * @throws {Error} If a required resource is not found (page-detail, html, resource-referrers). + */ +export async function dispatchQuery( + accessor: ArchiveAccessor, + subCommand: QuerySubCommand, + flags: QueryFlags, +): Promise { + const options = mapFlagsToQueryOptions(subCommand, flags); + + switch (subCommand) { + case 'summary': { + return getSummary(accessor); + } + case 'pages': { + return listPages(accessor, options as ListPagesOptions); + } + case 'page-detail': { + const { url } = options as { url: string }; + const result = await getPageDetail(accessor, url); + if (!result) { + throw new Error(`Page not found: ${url}`); + } + return result; + } + case 'html': { + const { url, maxLength } = options as { url: string; maxLength?: number }; + const result = await getPageHtml(accessor, url, maxLength); + if (!result) { + throw new Error(`Page HTML not found: ${url}`); + } + return result; + } + case 'links': { + return listLinks(accessor, options as ListLinksOptions); + } + case 'resources': { + return listResources(accessor, options as ListResourcesOptions); + } + case 'images': { + return listImages(accessor, options as ListImagesOptions); + } + case 'violations': { + return getViolations(accessor, options as GetViolationsOptions); + } + case 'duplicates': { + const { field, limit } = options as { + field: 'title' | 'description'; + limit?: number; + }; + return findDuplicates(accessor, field, limit); + } + case 'mismatches': { + const { type, limit, offset } = options as { + type: 'canonical' | 'og:title' | 'og:description'; + limit?: number; + offset?: number; + }; + return findMismatches(accessor, type, limit, offset); + } + case 'headers': { + return checkHeaders( + accessor, + options as { limit?: number; offset?: number; missingOnly?: boolean }, + ); + } + case 'resource-referrers': { + const { url } = options as { url: string }; + const result = await getResourceReferrers(accessor, url); + if (!result) { + throw new Error(`Resource not found: ${url}`); + } + return result; + } + } +} diff --git a/packages/@nitpicker/cli/src/query/map-flags-to-query-options.spec.ts b/packages/@nitpicker/cli/src/query/map-flags-to-query-options.spec.ts new file mode 100644 index 0000000..5692311 --- /dev/null +++ b/packages/@nitpicker/cli/src/query/map-flags-to-query-options.spec.ts @@ -0,0 +1,196 @@ +import { describe, it, expect } from 'vitest'; + +import { mapFlagsToQueryOptions } from './map-flags-to-query-options.js'; + +describe('mapFlagsToQueryOptions', () => { + it('returns empty object for summary', () => { + expect(mapFlagsToQueryOptions('summary', {})).toEqual({}); + }); + + it('maps pages flags correctly', () => { + const result = mapFlagsToQueryOptions('pages', { + status: 404, + statusMin: 400, + statusMax: 499, + isExternal: false, + missingTitle: true, + sortBy: 'status', + sortOrder: 'desc', + limit: 50, + offset: 10, + }); + expect(result).toEqual({ + status: 404, + statusMin: 400, + statusMax: 499, + isExternal: false, + missingTitle: true, + missingDescription: undefined, + noindex: undefined, + urlPattern: undefined, + directory: undefined, + sortBy: 'status', + sortOrder: 'desc', + limit: 50, + offset: 10, + }); + }); + + it('throws for invalid sortBy value', () => { + expect(() => mapFlagsToQueryOptions('pages', { sortBy: 'invalid' })).toThrow( + 'Invalid --sortBy value', + ); + }); + + it('throws for invalid sortOrder value', () => { + expect(() => mapFlagsToQueryOptions('pages', { sortOrder: 'invalid' })).toThrow( + 'Invalid --sortOrder value', + ); + }); + + it('requires --url for page-detail', () => { + expect(() => mapFlagsToQueryOptions('page-detail', {})).toThrow( + '--url is required for the page-detail sub-command', + ); + }); + + it('returns url for page-detail', () => { + expect(mapFlagsToQueryOptions('page-detail', { url: 'https://example.com' })).toEqual( + { + url: 'https://example.com', + }, + ); + }); + + it('requires --url for html', () => { + expect(() => mapFlagsToQueryOptions('html', {})).toThrow( + '--url is required for the html sub-command', + ); + }); + + it('returns url and maxLength for html', () => { + expect( + mapFlagsToQueryOptions('html', { url: 'https://example.com', maxLength: 5000 }), + ).toEqual({ + url: 'https://example.com', + maxLength: 5000, + }); + }); + + it('requires --type for links', () => { + expect(() => mapFlagsToQueryOptions('links', {})).toThrow( + '--type is required for the links sub-command', + ); + }); + + it('throws for invalid links type', () => { + expect(() => mapFlagsToQueryOptions('links', { type: 'invalid' })).toThrow( + 'Invalid --type value', + ); + }); + + it('maps links flags correctly', () => { + expect( + mapFlagsToQueryOptions('links', { type: 'broken', limit: 20, offset: 5 }), + ).toEqual({ + type: 'broken', + limit: 20, + offset: 5, + }); + }); + + it('maps resources flags correctly', () => { + expect( + mapFlagsToQueryOptions('resources', { contentType: 'text/css', limit: 10 }), + ).toEqual({ + contentType: 'text/css', + isExternal: undefined, + limit: 10, + offset: undefined, + }); + }); + + it('maps images flags correctly', () => { + expect( + mapFlagsToQueryOptions('images', { missingAlt: true, oversizedThreshold: 1000 }), + ).toEqual({ + missingAlt: true, + missingDimensions: undefined, + oversizedThreshold: 1000, + urlPattern: undefined, + limit: undefined, + offset: undefined, + }); + }); + + it('maps violations flags correctly', () => { + expect( + mapFlagsToQueryOptions('violations', { validator: 'axe', severity: 'critical' }), + ).toEqual({ + validator: 'axe', + severity: 'critical', + rule: undefined, + limit: undefined, + offset: undefined, + }); + }); + + it('defaults duplicates field to title', () => { + expect(mapFlagsToQueryOptions('duplicates', {})).toEqual({ + field: 'title', + limit: undefined, + }); + }); + + it('throws for invalid duplicates field', () => { + expect(() => mapFlagsToQueryOptions('duplicates', { field: 'invalid' })).toThrow( + 'Invalid --field value', + ); + }); + + it('requires --type for mismatches', () => { + expect(() => mapFlagsToQueryOptions('mismatches', {})).toThrow( + '--type is required for the mismatches sub-command', + ); + }); + + it('throws for invalid mismatches type', () => { + expect(() => mapFlagsToQueryOptions('mismatches', { type: 'broken' })).toThrow( + 'Invalid --type value', + ); + }); + + it('maps mismatches flags correctly', () => { + expect( + mapFlagsToQueryOptions('mismatches', { type: 'canonical', limit: 50 }), + ).toEqual({ + type: 'canonical', + limit: 50, + offset: undefined, + }); + }); + + it('maps headers flags correctly', () => { + expect(mapFlagsToQueryOptions('headers', { missingOnly: true, limit: 25 })).toEqual({ + limit: 25, + offset: undefined, + missingOnly: true, + }); + }); + + it('requires --url for resource-referrers', () => { + expect(() => mapFlagsToQueryOptions('resource-referrers', {})).toThrow( + '--url is required for the resource-referrers sub-command', + ); + }); + + it('returns url for resource-referrers', () => { + expect( + mapFlagsToQueryOptions('resource-referrers', { + url: 'https://example.com/style.css', + }), + ).toEqual({ + url: 'https://example.com/style.css', + }); + }); +}); diff --git a/packages/@nitpicker/cli/src/query/map-flags-to-query-options.ts b/packages/@nitpicker/cli/src/query/map-flags-to-query-options.ts new file mode 100644 index 0000000..87bf1e3 --- /dev/null +++ b/packages/@nitpicker/cli/src/query/map-flags-to-query-options.ts @@ -0,0 +1,203 @@ +import type { QuerySubCommand } from './types.js'; + +/** + * Flat CLI flags passed to the query command. + * All fields are optional since they come from parsed CLI arguments. + */ +interface QueryFlags { + /** Maximum number of results to return. */ + limit?: number; + /** Number of results to skip. */ + offset?: number; + /** Target URL for page-detail, html, or resource-referrers queries. */ + url?: string; + /** Filter by exact HTTP status code. */ + status?: number; + /** Filter by minimum HTTP status code (inclusive). */ + statusMin?: number; + /** Filter by maximum HTTP status code (inclusive). */ + statusMax?: number; + /** Filter by external (true) or internal (false). */ + isExternal?: boolean; + /** Filter to pages missing title. */ + missingTitle?: boolean; + /** Filter to pages missing description. */ + missingDescription?: boolean; + /** Filter to pages with noindex. */ + noindex?: boolean; + /** URL pattern to filter (SQL LIKE pattern). */ + urlPattern?: string; + /** Directory path prefix to filter by. */ + directory?: string; + /** Field to sort by (url, status, title). */ + sortBy?: string; + /** Sort direction (asc, desc). */ + sortOrder?: string; + /** Filter type for links or mismatches sub-commands. */ + type?: string; + /** Filter by content type prefix. */ + contentType?: string; + /** Filter to images missing alt attribute. */ + missingAlt?: boolean; + /** Filter to images missing width/height. */ + missingDimensions?: boolean; + /** Filter to images exceeding this dimension threshold. */ + oversizedThreshold?: number; + /** Filter by validator name. */ + validator?: string; + /** Filter by severity level. */ + severity?: string; + /** Filter by rule ID. */ + rule?: string; + /** Field to check for duplicates (title, description). */ + field?: string; + /** Only show pages missing security headers. */ + missingOnly?: boolean; + /** Maximum HTML length to return. */ + maxLength?: number; +} + +/** + * Builds the options object for a specific query function from flat CLI flags. + * + * Validates required flags per sub-command and returns the appropriate + * options shape for the corresponding `@nitpicker/query` function. + * @param subCommand - The query sub-command name. + * @param flags - The parsed CLI flags. + * @returns The options object appropriate for the sub-command's query function. + * @throws {Error} If required flags are missing or have invalid values. + */ +export function mapFlagsToQueryOptions( + subCommand: QuerySubCommand, + flags: QueryFlags, +): unknown { + switch (subCommand) { + case 'summary': { + return {}; + } + case 'pages': { + if (flags.sortBy != null && !['url', 'status', 'title'].includes(flags.sortBy)) { + throw new Error( + `Invalid --sortBy value: ${flags.sortBy}. Must be one of: url, status, title`, + ); + } + if (flags.sortOrder != null && !['asc', 'desc'].includes(flags.sortOrder)) { + throw new Error( + `Invalid --sortOrder value: ${flags.sortOrder}. Must be one of: asc, desc`, + ); + } + return { + status: flags.status, + statusMin: flags.statusMin, + statusMax: flags.statusMax, + isExternal: flags.isExternal, + missingTitle: flags.missingTitle, + missingDescription: flags.missingDescription, + noindex: flags.noindex, + urlPattern: flags.urlPattern, + directory: flags.directory, + sortBy: flags.sortBy as 'url' | 'status' | 'title' | undefined, + sortOrder: flags.sortOrder as 'asc' | 'desc' | undefined, + limit: flags.limit, + offset: flags.offset, + }; + } + case 'page-detail': { + if (!flags.url) { + throw new Error('--url is required for the page-detail sub-command.'); + } + return { url: flags.url }; + } + case 'html': { + if (!flags.url) { + throw new Error('--url is required for the html sub-command.'); + } + return { url: flags.url, maxLength: flags.maxLength }; + } + case 'links': { + if (!flags.type) { + throw new Error( + '--type is required for the links sub-command. Must be one of: broken, external, orphaned', + ); + } + if (!['broken', 'external', 'orphaned'].includes(flags.type)) { + throw new Error( + `Invalid --type value: ${flags.type}. Must be one of: broken, external, orphaned`, + ); + } + return { + type: flags.type as 'broken' | 'external' | 'orphaned', + limit: flags.limit, + offset: flags.offset, + }; + } + case 'resources': { + return { + contentType: flags.contentType, + isExternal: flags.isExternal, + limit: flags.limit, + offset: flags.offset, + }; + } + case 'images': { + return { + missingAlt: flags.missingAlt, + missingDimensions: flags.missingDimensions, + oversizedThreshold: flags.oversizedThreshold, + urlPattern: flags.urlPattern, + limit: flags.limit, + offset: flags.offset, + }; + } + case 'violations': { + return { + validator: flags.validator, + severity: flags.severity, + rule: flags.rule, + limit: flags.limit, + offset: flags.offset, + }; + } + case 'duplicates': { + if (flags.field != null && !['title', 'description'].includes(flags.field)) { + throw new Error( + `Invalid --field value: ${flags.field}. Must be one of: title, description`, + ); + } + return { + field: (flags.field as 'title' | 'description' | undefined) ?? 'title', + limit: flags.limit, + }; + } + case 'mismatches': { + if (!flags.type) { + throw new Error( + '--type is required for the mismatches sub-command. Must be one of: canonical, og:title, og:description', + ); + } + if (!['canonical', 'og:title', 'og:description'].includes(flags.type)) { + throw new Error( + `Invalid --type value: ${flags.type}. Must be one of: canonical, og:title, og:description`, + ); + } + return { + type: flags.type as 'canonical' | 'og:title' | 'og:description', + limit: flags.limit, + offset: flags.offset, + }; + } + case 'headers': { + return { + limit: flags.limit, + offset: flags.offset, + missingOnly: flags.missingOnly, + }; + } + case 'resource-referrers': { + if (!flags.url) { + throw new Error('--url is required for the resource-referrers sub-command.'); + } + return { url: flags.url }; + } + } +} diff --git a/packages/@nitpicker/cli/src/query/types.ts b/packages/@nitpicker/cli/src/query/types.ts new file mode 100644 index 0000000..11aabb1 --- /dev/null +++ b/packages/@nitpicker/cli/src/query/types.ts @@ -0,0 +1,34 @@ +/** + * Valid sub-command names for the query command. + */ +export type QuerySubCommand = + | 'summary' + | 'pages' + | 'page-detail' + | 'html' + | 'links' + | 'resources' + | 'images' + | 'violations' + | 'duplicates' + | 'mismatches' + | 'headers' + | 'resource-referrers'; + +/** + * List of all valid query sub-command names. + */ +export const VALID_SUB_COMMANDS: readonly QuerySubCommand[] = [ + 'summary', + 'pages', + 'page-detail', + 'html', + 'links', + 'resources', + 'images', + 'violations', + 'duplicates', + 'mismatches', + 'headers', + 'resource-referrers', +]; diff --git a/packages/@nitpicker/cli/tsconfig.json b/packages/@nitpicker/cli/tsconfig.json index 3f2366b..f04cea3 100644 --- a/packages/@nitpicker/cli/tsconfig.json +++ b/packages/@nitpicker/cli/tsconfig.json @@ -8,6 +8,7 @@ "references": [ { "path": "../core" }, { "path": "../crawler" }, + { "path": "../query" }, { "path": "../report-google-sheets" } ], "include": ["./src/**/*"], diff --git a/yarn.lock b/yarn.lock index 9d2d322..e538c11 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2385,6 +2385,7 @@ __metadata: "@nitpicker/analyze-textlint": "npm:0.4.4" "@nitpicker/core": "npm:0.4.4" "@nitpicker/crawler": "npm:0.4.4" + "@nitpicker/query": "npm:0.4.4" "@nitpicker/report-google-sheets": "npm:0.4.4" "@types/debug": "npm:4.1.12" ansi-colors: "npm:4.1.3" From 656e51c2338c6a7a0f3349fe298f72ab190af6ad Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Mar 2026 09:49:16 +0000 Subject: [PATCH 2/4] fix: address QA review findings for query CLI command - Add toHaveBeenCalledWith assertions to dispatch-query tests for all sub-commands - Add dispatchQuery argument verification and ArchiveManager.open failure test to query command tests - Remove duplicate QueryFlags interface, reuse InferFlags - Wrap finally close in try/catch to prevent masking original errors - Use `as const satisfies` for VALID_SUB_COMMANDS type safety - Add test cases for duplicates with custom field/limit and mismatches with limit/offset https://claude.ai/code/session_01RgcxfZHjSbBBcT7df5BymS --- .../@nitpicker/cli/src/commands/query.spec.ts | 24 +++++- packages/@nitpicker/cli/src/commands/query.ts | 6 +- .../cli/src/query/dispatch-query.spec.ts | 74 +++++++++++++++++-- .../src/query/map-flags-to-query-options.ts | 60 +-------------- packages/@nitpicker/cli/src/query/types.ts | 4 +- 5 files changed, 100 insertions(+), 68 deletions(-) diff --git a/packages/@nitpicker/cli/src/commands/query.spec.ts b/packages/@nitpicker/cli/src/commands/query.spec.ts index 151eba2..1856540 100644 --- a/packages/@nitpicker/cli/src/commands/query.spec.ts +++ b/packages/@nitpicker/cli/src/commands/query.spec.ts @@ -88,12 +88,34 @@ describe('query command', () => { it('outputs JSON result to stdout on success', async () => { await query(['test.nitpicker', 'summary'], { pretty: undefined } as never); - expect(dispatchQueryFn).toHaveBeenCalled(); + expect(dispatchQueryFn).toHaveBeenCalledWith( + expect.anything(), + 'summary', + expect.objectContaining({ pretty: undefined }), + ); expect(consoleLogSpy).toHaveBeenCalledWith( JSON.stringify({ baseUrl: 'https://example.com', totalPages: 5 }), ); }); + it('exits with error when ArchiveManager.open fails', async () => { + const { ArchiveManager } = await import('@nitpicker/query'); + vi.mocked(ArchiveManager).mockImplementationOnce(function (this: { + open: ReturnType; + close: ReturnType; + }) { + this.open = vi.fn().mockRejectedValue(new Error('Failed to open archive')); + this.close = vi.fn().mockResolvedValue(); + } as never); + + await expect( + query(['test.nitpicker', 'summary'], { pretty: undefined } as never), + ).rejects.toThrow(ExitError); + + expect(formatCliErrorFn).toHaveBeenCalledWith(expect.any(Error), false); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + it('pretty-prints when --pretty is set', async () => { await query(['test.nitpicker', 'summary'], { pretty: true } as never); diff --git a/packages/@nitpicker/cli/src/commands/query.ts b/packages/@nitpicker/cli/src/commands/query.ts index 9fad9d1..f15615f 100644 --- a/packages/@nitpicker/cli/src/commands/query.ts +++ b/packages/@nitpicker/cli/src/commands/query.ts @@ -176,7 +176,11 @@ export async function query(args: string[], flags: QueryFlags) { // eslint-disable-next-line no-console console.log(output); } finally { - await manager.close(archiveId); + try { + await manager.close(archiveId); + } catch { + // close failure should not mask the original error + } } } catch (error) { formatCliError(error, false); diff --git a/packages/@nitpicker/cli/src/query/dispatch-query.spec.ts b/packages/@nitpicker/cli/src/query/dispatch-query.spec.ts index 1720a46..bf2e31d 100644 --- a/packages/@nitpicker/cli/src/query/dispatch-query.spec.ts +++ b/packages/@nitpicker/cli/src/query/dispatch-query.spec.ts @@ -18,13 +18,11 @@ vi.mock('@nitpicker/query', () => ({ findDuplicates: vi.fn().mockResolvedValue([]), findMismatches: vi.fn().mockResolvedValue([]), checkHeaders: vi.fn().mockResolvedValue({ items: [], total: 0, offset: 0, limit: 100 }), - getResourceReferrers: vi - .fn() - .mockResolvedValue({ - resourceUrl: 'https://example.com/style.css', - pageUrls: [], - total: 0, - }), + getResourceReferrers: vi.fn().mockResolvedValue({ + resourceUrl: 'https://example.com/style.css', + pageUrls: [], + total: 0, + }), ArchiveManager: vi.fn(), })); @@ -44,20 +42,26 @@ describe('dispatchQuery', () => { }); it('dispatches summary sub-command', async () => { + const { getSummary } = await import('@nitpicker/query'); const result = await dispatchQuery(mockAccessor, 'summary', emptyFlags); expect(result).toEqual({ baseUrl: 'https://example.com', totalPages: 10 }); + expect(getSummary).toHaveBeenCalledWith(mockAccessor); }); it('dispatches pages sub-command', async () => { + const { listPages } = await import('@nitpicker/query'); const result = await dispatchQuery(mockAccessor, 'pages', emptyFlags); expect(result).toEqual({ items: [], total: 0, offset: 0, limit: 100 }); + expect(listPages).toHaveBeenCalledWith(mockAccessor, expect.any(Object)); }); it('dispatches page-detail sub-command', async () => { + const { getPageDetail } = await import('@nitpicker/query'); const result = await dispatchQuery(mockAccessor, 'page-detail', { url: 'https://example.com', } as never); expect(result).toEqual({ url: 'https://example.com', status: 200 }); + expect(getPageDetail).toHaveBeenCalledWith(mockAccessor, 'https://example.com'); }); it('throws when page-detail returns null', async () => { @@ -70,10 +74,16 @@ describe('dispatchQuery', () => { }); it('dispatches html sub-command', async () => { + const { getPageHtml } = await import('@nitpicker/query'); const result = await dispatchQuery(mockAccessor, 'html', { url: 'https://example.com', } as never); expect(result).toEqual({ html: '', truncated: false }); + expect(getPageHtml).toHaveBeenCalledWith( + mockAccessor, + 'https://example.com', + undefined, + ); }); it('throws when html returns null', async () => { @@ -86,45 +96,89 @@ describe('dispatchQuery', () => { }); it('dispatches links sub-command', async () => { + const { listLinks } = await import('@nitpicker/query'); const result = await dispatchQuery(mockAccessor, 'links', { type: 'broken', } as never); expect(result).toEqual({ items: [], total: 0 }); + expect(listLinks).toHaveBeenCalledWith( + mockAccessor, + expect.objectContaining({ type: 'broken' }), + ); }); it('dispatches resources sub-command', async () => { + const { listResources } = await import('@nitpicker/query'); const result = await dispatchQuery(mockAccessor, 'resources', emptyFlags); expect(result).toEqual({ items: [], total: 0, offset: 0, limit: 100 }); + expect(listResources).toHaveBeenCalledWith(mockAccessor, expect.any(Object)); }); it('dispatches images sub-command', async () => { + const { listImages } = await import('@nitpicker/query'); const result = await dispatchQuery(mockAccessor, 'images', emptyFlags); expect(result).toEqual({ items: [], total: 0, offset: 0, limit: 100 }); + expect(listImages).toHaveBeenCalledWith(mockAccessor, expect.any(Object)); }); it('dispatches violations sub-command', async () => { + const { getViolations } = await import('@nitpicker/query'); const result = await dispatchQuery(mockAccessor, 'violations', emptyFlags); expect(result).toEqual({ items: [], total: 0 }); + expect(getViolations).toHaveBeenCalledWith(mockAccessor, expect.any(Object)); }); - it('dispatches duplicates sub-command', async () => { + it('dispatches duplicates sub-command with default field', async () => { + const { findDuplicates } = await import('@nitpicker/query'); const result = await dispatchQuery(mockAccessor, 'duplicates', emptyFlags); expect(result).toEqual([]); + expect(findDuplicates).toHaveBeenCalledWith(mockAccessor, 'title', undefined); + }); + + it('dispatches duplicates sub-command with custom field and limit', async () => { + const { findDuplicates } = await import('@nitpicker/query'); + const result = await dispatchQuery(mockAccessor, 'duplicates', { + field: 'description', + limit: 10, + } as never); + expect(result).toEqual([]); + expect(findDuplicates).toHaveBeenCalledWith(mockAccessor, 'description', 10); }); it('dispatches mismatches sub-command', async () => { + const { findMismatches } = await import('@nitpicker/query'); const result = await dispatchQuery(mockAccessor, 'mismatches', { type: 'canonical', } as never); expect(result).toEqual([]); + expect(findMismatches).toHaveBeenCalledWith( + mockAccessor, + 'canonical', + undefined, + undefined, + ); + }); + + it('dispatches mismatches sub-command with limit and offset', async () => { + const { findMismatches } = await import('@nitpicker/query'); + const result = await dispatchQuery(mockAccessor, 'mismatches', { + type: 'og:title', + limit: 5, + offset: 10, + } as never); + expect(result).toEqual([]); + expect(findMismatches).toHaveBeenCalledWith(mockAccessor, 'og:title', 5, 10); }); it('dispatches headers sub-command', async () => { + const { checkHeaders } = await import('@nitpicker/query'); const result = await dispatchQuery(mockAccessor, 'headers', emptyFlags); expect(result).toEqual({ items: [], total: 0, offset: 0, limit: 100 }); + expect(checkHeaders).toHaveBeenCalledWith(mockAccessor, expect.any(Object)); }); it('dispatches resource-referrers sub-command', async () => { + const { getResourceReferrers } = await import('@nitpicker/query'); const result = await dispatchQuery(mockAccessor, 'resource-referrers', { url: 'https://example.com/style.css', } as never); @@ -133,6 +187,10 @@ describe('dispatchQuery', () => { pageUrls: [], total: 0, }); + expect(getResourceReferrers).toHaveBeenCalledWith( + mockAccessor, + 'https://example.com/style.css', + ); }); it('throws when resource-referrers returns null', async () => { diff --git a/packages/@nitpicker/cli/src/query/map-flags-to-query-options.ts b/packages/@nitpicker/cli/src/query/map-flags-to-query-options.ts index 87bf1e3..1e10bb8 100644 --- a/packages/@nitpicker/cli/src/query/map-flags-to-query-options.ts +++ b/packages/@nitpicker/cli/src/query/map-flags-to-query-options.ts @@ -1,61 +1,9 @@ import type { QuerySubCommand } from './types.js'; +import type { commandDef } from '../commands/query.js'; +import type { InferFlags } from '@d-zero/roar'; -/** - * Flat CLI flags passed to the query command. - * All fields are optional since they come from parsed CLI arguments. - */ -interface QueryFlags { - /** Maximum number of results to return. */ - limit?: number; - /** Number of results to skip. */ - offset?: number; - /** Target URL for page-detail, html, or resource-referrers queries. */ - url?: string; - /** Filter by exact HTTP status code. */ - status?: number; - /** Filter by minimum HTTP status code (inclusive). */ - statusMin?: number; - /** Filter by maximum HTTP status code (inclusive). */ - statusMax?: number; - /** Filter by external (true) or internal (false). */ - isExternal?: boolean; - /** Filter to pages missing title. */ - missingTitle?: boolean; - /** Filter to pages missing description. */ - missingDescription?: boolean; - /** Filter to pages with noindex. */ - noindex?: boolean; - /** URL pattern to filter (SQL LIKE pattern). */ - urlPattern?: string; - /** Directory path prefix to filter by. */ - directory?: string; - /** Field to sort by (url, status, title). */ - sortBy?: string; - /** Sort direction (asc, desc). */ - sortOrder?: string; - /** Filter type for links or mismatches sub-commands. */ - type?: string; - /** Filter by content type prefix. */ - contentType?: string; - /** Filter to images missing alt attribute. */ - missingAlt?: boolean; - /** Filter to images missing width/height. */ - missingDimensions?: boolean; - /** Filter to images exceeding this dimension threshold. */ - oversizedThreshold?: number; - /** Filter by validator name. */ - validator?: string; - /** Filter by severity level. */ - severity?: string; - /** Filter by rule ID. */ - rule?: string; - /** Field to check for duplicates (title, description). */ - field?: string; - /** Only show pages missing security headers. */ - missingOnly?: boolean; - /** Maximum HTML length to return. */ - maxLength?: number; -} +/** Parsed flag values for the query CLI command. */ +type QueryFlags = InferFlags; /** * Builds the options object for a specific query function from flat CLI flags. diff --git a/packages/@nitpicker/cli/src/query/types.ts b/packages/@nitpicker/cli/src/query/types.ts index 11aabb1..378d850 100644 --- a/packages/@nitpicker/cli/src/query/types.ts +++ b/packages/@nitpicker/cli/src/query/types.ts @@ -18,7 +18,7 @@ export type QuerySubCommand = /** * List of all valid query sub-command names. */ -export const VALID_SUB_COMMANDS: readonly QuerySubCommand[] = [ +export const VALID_SUB_COMMANDS = [ 'summary', 'pages', 'page-detail', @@ -31,4 +31,4 @@ export const VALID_SUB_COMMANDS: readonly QuerySubCommand[] = [ 'mismatches', 'headers', 'resource-referrers', -]; +] as const satisfies readonly QuerySubCommand[]; From 0243befef5f5b19b2caca767974649e0f530256c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 15:10:50 +0000 Subject: [PATCH 3/4] fix: add exhaustive switch checks and improve test coverage for query CLI - Add default exhaustive check (never) to dispatchQuery and mapFlagsToQueryOptions switch statements - Add test for html sub-command with maxLength flag - Add test for headers sub-command with missingOnly flag - Add tests for relative and absolute file path resolution in query command https://claude.ai/code/session_01RgcxfZHjSbBBcT7df5BymS --- .../@nitpicker/cli/src/commands/query.spec.ts | 20 +++++++++++++++++ .../cli/src/query/dispatch-query.spec.ts | 22 +++++++++++++++++++ .../cli/src/query/dispatch-query.ts | 4 ++++ .../src/query/map-flags-to-query-options.ts | 4 ++++ 4 files changed, 50 insertions(+) diff --git a/packages/@nitpicker/cli/src/commands/query.spec.ts b/packages/@nitpicker/cli/src/commands/query.spec.ts index 1856540..58393cd 100644 --- a/packages/@nitpicker/cli/src/commands/query.spec.ts +++ b/packages/@nitpicker/cli/src/commands/query.spec.ts @@ -1,3 +1,5 @@ +import path from 'node:path'; + import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; import { formatCliError as formatCliErrorFn } from '../format-cli-error.js'; @@ -116,6 +118,24 @@ describe('query command', () => { expect(exitSpy).toHaveBeenCalledWith(1); }); + it('resolves relative file path via process.cwd()', async () => { + const { ArchiveManager } = await import('@nitpicker/query'); + await query(['relative/test.nitpicker', 'summary'], { pretty: undefined } as never); + + const managerInstance = vi.mocked(ArchiveManager).mock.results[0]?.value; + expect(managerInstance.open).toHaveBeenCalledWith( + path.resolve(process.cwd(), 'relative/test.nitpicker'), + ); + }); + + it('uses absolute file path as-is', async () => { + const { ArchiveManager } = await import('@nitpicker/query'); + await query(['/absolute/test.nitpicker', 'summary'], { pretty: undefined } as never); + + const managerInstance = vi.mocked(ArchiveManager).mock.results[0]?.value; + expect(managerInstance.open).toHaveBeenCalledWith('/absolute/test.nitpicker'); + }); + it('pretty-prints when --pretty is set', async () => { await query(['test.nitpicker', 'summary'], { pretty: true } as never); diff --git a/packages/@nitpicker/cli/src/query/dispatch-query.spec.ts b/packages/@nitpicker/cli/src/query/dispatch-query.spec.ts index bf2e31d..cced4eb 100644 --- a/packages/@nitpicker/cli/src/query/dispatch-query.spec.ts +++ b/packages/@nitpicker/cli/src/query/dispatch-query.spec.ts @@ -86,6 +86,16 @@ describe('dispatchQuery', () => { ); }); + it('dispatches html sub-command with maxLength', async () => { + const { getPageHtml } = await import('@nitpicker/query'); + const result = await dispatchQuery(mockAccessor, 'html', { + url: 'https://example.com', + maxLength: 5000, + } as never); + expect(result).toEqual({ html: '', truncated: false }); + expect(getPageHtml).toHaveBeenCalledWith(mockAccessor, 'https://example.com', 5000); + }); + it('throws when html returns null', async () => { const { getPageHtml } = await import('@nitpicker/query'); vi.mocked(getPageHtml).mockResolvedValueOnce(null); @@ -177,6 +187,18 @@ describe('dispatchQuery', () => { expect(checkHeaders).toHaveBeenCalledWith(mockAccessor, expect.any(Object)); }); + it('dispatches headers sub-command with missingOnly', async () => { + const { checkHeaders } = await import('@nitpicker/query'); + await dispatchQuery(mockAccessor, 'headers', { + missingOnly: true, + limit: 25, + } as never); + expect(checkHeaders).toHaveBeenCalledWith( + mockAccessor, + expect.objectContaining({ missingOnly: true, limit: 25 }), + ); + }); + it('dispatches resource-referrers sub-command', async () => { const { getResourceReferrers } = await import('@nitpicker/query'); const result = await dispatchQuery(mockAccessor, 'resource-referrers', { diff --git a/packages/@nitpicker/cli/src/query/dispatch-query.ts b/packages/@nitpicker/cli/src/query/dispatch-query.ts index d2c69b4..8bc6bae 100644 --- a/packages/@nitpicker/cli/src/query/dispatch-query.ts +++ b/packages/@nitpicker/cli/src/query/dispatch-query.ts @@ -113,5 +113,9 @@ export async function dispatchQuery( } return result; } + default: { + const _exhaustive: never = subCommand; + throw new Error(`Unknown sub-command: ${String(_exhaustive)}`); + } } } diff --git a/packages/@nitpicker/cli/src/query/map-flags-to-query-options.ts b/packages/@nitpicker/cli/src/query/map-flags-to-query-options.ts index 1e10bb8..39a518f 100644 --- a/packages/@nitpicker/cli/src/query/map-flags-to-query-options.ts +++ b/packages/@nitpicker/cli/src/query/map-flags-to-query-options.ts @@ -147,5 +147,9 @@ export function mapFlagsToQueryOptions( } return { url: flags.url }; } + default: { + const _exhaustive: never = subCommand; + throw new Error(`Unknown sub-command: ${String(_exhaustive)}`); + } } } From 608759740ac0edd4c32e24ebf00739fb779be6c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Mar 2026 11:12:07 +0000 Subject: [PATCH 4/4] docs: add query CLI command documentation to README, ARCHITECTURE, and CLAUDE The query command was added in the previous commits but documentation was not updated. This adds comprehensive usage documentation including all 12 sub-commands, option tables, and examples. https://claude.ai/code/session_01RgcxfZHjSbBBcT7df5BymS --- ARCHITECTURE.md | 3 +- CLAUDE.md | 9 ++--- README.md | 95 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 5 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 349246d..c8d6c9e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -196,12 +196,13 @@ crawler/src/ ### @nitpicker/cli -`@d-zero/roar` ベースの統合 CLI。4つのサブコマンドを提供。全 analyze プラグインを `dependencies` に含んでおり、`npx` 実行時に `@nitpicker/core` の動的 `import()` がプラグインモジュールを解決できるようにしている。 +`@d-zero/roar` ベースの統合 CLI。5つのサブコマンドを提供。全 analyze プラグインを `dependencies` に含んでおり、`npx` 実行時に `@nitpicker/core` の動的 `import()` がプラグインモジュールを解決できるようにしている。 - **`npx @nitpicker/cli crawl `**: Webサイトをクロールして `.nitpicker` ファイルを生成 - **`npx @nitpicker/cli analyze `**: `.nitpicker` ファイルに対して analyze プラグインを実行。`--search-keywords`, `--axe-lang` 等のフラグで設定ファイルのプラグイン設定を上書き可能(`buildPluginOverrides()` → `Nitpicker.setPluginOverrides()` 経由) - **`npx @nitpicker/cli report `**: `.nitpicker` ファイルから Google Sheets レポートを生成 - **`npx @nitpicker/cli pipeline `**: crawl → analyze → report を直列実行。`startCrawl()` でアーカイブパスを取得し、そのパスを `analyze()` と `report()` に引き渡す。`--sheet` 指定時のみ report ステップを実行 +- **`npx @nitpicker/cli query `**: `.nitpicker` ファイルに対してクエリを実行し、結果を JSON で出力。`@nitpicker/query` の全関数を CLI から利用可能。12 のサブコマンド(`summary`, `pages`, `page-detail`, `html`, `links`, `resources`, `images`, `violations`, `duplicates`, `mismatches`, `headers`, `resource-referrers`)を提供 --- diff --git a/CLAUDE.md b/CLAUDE.md index 792c1f8..e57428d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,10 +48,11 @@ packages/ ## CLI コマンド ```sh -npx @nitpicker/cli crawl [options] # Web サイトをクロールして .nitpicker ファイルを生成 -npx @nitpicker/cli analyze [options] # .nitpicker ファイルに対して analyze プラグインを実行 -npx @nitpicker/cli report [options] # .nitpicker ファイルから Google Sheets レポートを生成 -npx @nitpicker/cli pipeline [options] # crawl → analyze → report を直列実行 +npx @nitpicker/cli crawl [options] # Web サイトをクロールして .nitpicker ファイルを生成 +npx @nitpicker/cli analyze [options] # .nitpicker ファイルに対して analyze プラグインを実行 +npx @nitpicker/cli report [options] # .nitpicker ファイルから Google Sheets レポートを生成 +npx @nitpicker/cli pipeline [options] # crawl → analyze → report を直列実行 +npx @nitpicker/cli query [options] # .nitpicker ファイルに対してクエリを実行し JSON 出力 ``` ## 主要アーキテクチャ diff --git a/README.md b/README.md index a2f6588..8cd198d 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,101 @@ $ npx @nitpicker/cli pipeline https://example.com --all --silent --strict crawl コマンドと同じ終了コード体系に従う。詳細は [crawl の終了コード](#終了コード) を参照。 +### Query + +`.nitpicker` アーカイブファイルに対してクエリを実行し、結果を JSON で出力する。MCP サーバーと同等のクエリ機能を CLI から利用できる。 + +```sh +$ npx @nitpicker/cli query [options] +``` + +#### サブコマンド + +| サブコマンド | 説明 | 必須オプション | +| -------------------- | -------------------------------------------------------------- | -------------- | +| `summary` | サイト全体の概要(ページ数、ステータス分布、メタデータ充足率) | なし | +| `pages` | ページ一覧(ステータス・メタデータ欠損・noindex 等で絞り込み) | なし | +| `page-detail` | 特定ページの全詳細(メタデータ、リンク、リダイレクト) | `--url` | +| `html` | ページの HTML スナップショットを取得 | `--url` | +| `links` | リンク分析(broken / external / orphaned) | `--type` | +| `resources` | サブリソース一覧(CSS, JS, 画像、フォント) | なし | +| `images` | 画像一覧(alt 欠損、寸法欠損、オーバーサイズ検出) | なし | +| `violations` | 分析プラグインの違反データ | なし | +| `duplicates` | 重複タイトル・説明の検出 | なし | +| `mismatches` | メタデータ不一致の検出 | `--type` | +| `headers` | セキュリティヘッダーチェック | なし | +| `resource-referrers` | 特定リソースを参照しているページの特定 | `--url` | + +#### オプション + +| オプション | 値 | デフォルト | 説明 | +| ---------------------- | ------ | ---------- | --------------------------------------------------------------------------------------------------- | +| `--limit` `-l` | 数値 | なし | 最大結果数 | +| `--offset` `-o` | 数値 | なし | スキップする結果数 | +| `--url` | URL | なし | 対象 URL(`page-detail`, `html`, `resource-referrers` で必須) | +| `--status` | 数値 | なし | HTTP ステータスコードで絞り込み | +| `--statusMin` | 数値 | なし | 最小 HTTP ステータスコード(以上) | +| `--statusMax` | 数値 | なし | 最大 HTTP ステータスコード(以下) | +| `--isExternal` | なし | なし | 外部ページのみ表示 | +| `--missingTitle` | なし | なし | title 欠損ページのみ表示 | +| `--missingDescription` | なし | なし | description 欠損ページのみ表示 | +| `--noindex` | なし | なし | noindex ページのみ表示 | +| `--urlPattern` | 文字列 | なし | URL パターンで絞り込み(SQL LIKE パターン) | +| `--directory` | 文字列 | なし | ディレクトリパスプレフィックスで絞り込み | +| `--sortBy` | 文字列 | なし | ソートフィールド(`url`, `status`, `title`) | +| `--sortOrder` | 文字列 | なし | ソート方向(`asc`, `desc`) | +| `--type` | 文字列 | なし | `links`: `broken`, `external`, `orphaned` / `mismatches`: `canonical`, `og:title`, `og:description` | +| `--contentType` | 文字列 | なし | Content-Type プレフィックスで絞り込み(例: `text/css`) | +| `--missingAlt` | なし | なし | alt 属性欠損の画像のみ表示 | +| `--missingDimensions` | なし | なし | 寸法欠損の画像のみ表示 | +| `--oversizedThreshold` | 数値 | なし | 指定寸法を超える画像のみ表示 | +| `--validator` | 文字列 | なし | バリデータ名で絞り込み(例: `axe`, `markuplint`) | +| `--severity` | 文字列 | なし | 重要度で絞り込み | +| `--rule` | 文字列 | なし | ルール ID で絞り込み | +| `--field` | 文字列 | `title` | 重複チェック対象フィールド(`title`, `description`) | +| `--missingOnly` | なし | なし | セキュリティヘッダー欠損ページのみ表示 | +| `--maxLength` | 数値 | なし | 返却する HTML の最大長 | +| `--pretty` | なし | なし | JSON 出力を整形表示 | + +> **`--type` フラグの使い分け**: `links` サブコマンドでは `broken`, `external`, `orphaned` のいずれか、`mismatches` サブコマンドでは `canonical`, `og:title`, `og:description` のいずれかを指定する。 + +#### 例 + +```sh +# サイト概要を取得 +$ npx @nitpicker/cli query site.nitpicker summary + +# ページ一覧(ステータスコード 404 で絞り込み) +$ npx @nitpicker/cli query site.nitpicker pages --status 404 + +# 特定ページの詳細 +$ npx @nitpicker/cli query site.nitpicker page-detail --url "https://example.com/about" + +# HTML スナップショット取得(最大 10000 文字) +$ npx @nitpicker/cli query site.nitpicker html --url "https://example.com" --maxLength 10000 + +# リンク切れ一覧 +$ npx @nitpicker/cli query site.nitpicker links --type broken + +# alt 欠損画像の一覧 +$ npx @nitpicker/cli query site.nitpicker images --missingAlt + +# アクセシビリティ違反の一覧 +$ npx @nitpicker/cli query site.nitpicker violations --validator axe + +# 重複タイトルの検出 +$ npx @nitpicker/cli query site.nitpicker duplicates --field title + +# canonical 不一致の検出 +$ npx @nitpicker/cli query site.nitpicker mismatches --type canonical + +# セキュリティヘッダー欠損ページ +$ npx @nitpicker/cli query site.nitpicker headers --missingOnly + +# 整形出力 +$ npx @nitpicker/cli query site.nitpicker summary --pretty +``` + ### MCP Server `.nitpicker` アーカイブファイルを AI アシスタント(Claude 等)から直接クエリするための [Model Context Protocol](https://modelcontextprotocol.io/) サーバー。14 のツールを提供し、サイト構造・メタデータ・リンク・リソース・画像・セキュリティヘッダーなどを対話的に分析できる。