/** * CLI for svelte-docinfo. * * Static analysis for TypeScript and Svelte source code. * * @example * ```bash * # Analyze current project * svelte-docinfo * * # Analyze specific directory * svelte-docinfo ./packages/my-lib * * # Write to file * svelte-docinfo --output docs/library.json * ``` * * @module */ import {Command, type OptionValues} from 'commander'; import {writeFile, readFile} from 'node:fs/promises'; import {resolve} from 'node:path'; import picomatch from 'picomatch'; import {analyzeFromFiles} from './analyze.js'; import type {OnDuplicates} from './analyze-core.js'; import type {Discovery} from './discovery.js'; import {hasErrors} from './diagnostics.js'; import type {AnalysisLog} from './log.js'; import {compactReplacer} from './declaration-helpers.js'; /** Collect repeatable option values into an array. */ const collect = (value: string, previous: Array<string> | undefined): Array<string> => previous ? [...previous, value] : [value]; /** Allowed values for `--on-duplicates`. */ const ON_DUPLICATES_VALUES = ['throw', 'warn'] as const; type OnDuplicatesFlag = (typeof ON_DUPLICATES_VALUES)[number]; const isOnDuplicatesFlag = (value: string): value is OnDuplicatesFlag => (ON_DUPLICATES_VALUES as ReadonlyArray<string>).includes(value); /** Allowed values for `--discovery`. */ const DISCOVERY_VALUES = ['auto', 'exports', 'glob'] as const satisfies ReadonlyArray<Discovery>; const isDiscoveryFlag = (value: string): value is Discovery => (DISCOVERY_VALUES as ReadonlyArray<string>).includes(value); /** * CLI options parsed from command line arguments. */ export interface CliOptions { /** File patterns to include (undefined = use exports discovery or defaults). */ include?: Array<string>; /** * File patterns to exclude (undefined = use defaults — test and spec files). * * When provided, **fully replaces** the defaults — no array merge. Passing * a custom `--exclude` pattern drops the default test/spec filters unless * the caller re-includes them explicitly. */ exclude?: Array<string>; /** Output file path (undefined = stdout). */ output?: string; /** * Whether to resolve dependencies. Mapped from the `--no-resolve-dependencies` * flag (commander populates `true` by default; `false` when the flag is passed). * Optional here so external callers can omit it; treated as `true` when undefined. */ resolveDependencies?: boolean; /** * Discovery strategy (undefined = 'auto'). * * Mapped from `--discovery <auto|exports|glob>`. */ discovery?: Discovery; /** Dist directory name for exports-based discovery (undefined = 'dist'). */ distDir?: string; /** * Source directories relative to project root (undefined = ['src/lib']). * * Repeatable. Drives the implicit include glob in the glob-discovery * fallback (via `deriveIncludePatterns` inside `discoverSourceFiles`) so * custom source directories survive without needing an explicit `--include`. */ sourceDir?: Array<string>; /** * Source root for module path extraction, relative to project root * (undefined = single sourceDir or longest common prefix). * * Module paths in output are stripped of `<projectRoot>/<sourceRoot>/`. * Pass `.` (or `""`) to keep module paths project-relative — useful when * `sourceDir` entries share no common prefix. The `.` form is normalized * to `""` inside `normalizeSourceOptions`. */ sourceRoot?: string; /** * Behavior when duplicate declaration names are found across modules * (undefined = emit `duplicate_declaration` diagnostic, no dispatch). * * Duplicate detection always runs regardless of this option — the diagnostic * is the data, this option is the dispatch action. */ onDuplicates?: OnDuplicatesFlag; /** * Glob patterns to filter the emitted `modules` array against `ModuleJson.path` * (undefined = emit all analyzed modules). * * Repeatable. Output-only filter: full-project analysis still runs so re-exports, * dependents, and `alsoExportedFrom` stay correct against the complete owned set. * Diagnostics aren't filtered — they may reference modules dropped from output. */ only?: Array<string>; /** Whether to suppress info messages to stderr. Treated as `false` when undefined. */ quiet?: boolean; /** Whether to pretty-print JSON output. Treated as `false` when undefined. */ pretty?: boolean; } /** * Run the CLI with the given arguments. * * @param argv - command line arguments (defaults to `process.argv`) * @returns exit code: 0 for success, 1 if errors in diagnostics, 2 for CLI errors */ export const runCli = async (argv: Array<string> = process.argv): Promise<number> => { // Track exit code from action (commander actions don't return values) let exitCode = 0; const program = new Command(); // Read version from package.json using URL resolution (works in both source and dist). // Loud throw on lookup failure — silent fallback would let a broken install // report `0.0.0` for `--version` indefinitely. const pkg = await (async () => { // Try dist path first, then source path const attempted: Array<string> = []; for (const relativePath of ['../package.json', '../../package.json']) { const pkgUrl = new URL(relativePath, import.meta.url); attempted.push(pkgUrl.href); try { const content = await readFile(pkgUrl, 'utf-8'); return JSON.parse(content) as {version: string}; } catch { continue; } } throw new Error( `svelte-docinfo: failed to load package.json for version. Attempted: ${attempted.join(', ')}`, ); })(); program .name('svelte-docinfo') .description('Static analysis for TypeScript and Svelte source code') .version(pkg.version) .argument('[project-root]', 'Project root directory', process.cwd()) .option( '-i, --include <pattern>', 'Include pattern (repeatable, replaces exports discovery; ' + 'incompatible with --discovery exports)', collect, ) .option( '-e, --exclude <pattern>', 'Exclude glob (applied at discovery and analysis, repeatable; ' + 'fully replaces defaults — does not merge with **/*.test.ts, **/*.spec.ts)', collect, ) .option('-o, --output <file>', 'Output file (default: stdout; pass `-` for explicit stdout)') .option('--no-resolve-dependencies', 'Disable dependency resolution') .option( '--discovery <mode>', `Source file discovery strategy: ${DISCOVERY_VALUES.join('|')} ` + '(default: auto — exports first, glob fallback). ' + '`exports` is strict and fails if package.json exports is missing or empty ' + '(also incompatible with --include).', ) .option('--dist-dir <dir>', 'Dist directory for exports discovery (default: dist)') .option( '--source-dir <dir>', 'Source directory relative to project root (default: src/lib). ' + 'Repeatable for monorepos. Drives the implicit include glob for the ' + 'glob-discovery fallback when no --include is provided.', collect, ) .option( '--source-root <dir>', 'Prefix stripped from module paths in output (default: the source-dir, ' + 'or longest common prefix when multiple). Pass `.` to keep paths project-relative.', ) .option( '--on-duplicates <mode>', `Dispatch on duplicate declaration names across modules: ${ON_DUPLICATES_VALUES.join('|')} ` + '(default: emit duplicate_declaration diagnostic, no dispatch)', ) .option( '--only <pattern>', 'Glob filter applied to module paths in output (repeatable). ' + 'Full project is still analyzed — re-exports/dependents stay correct — ' + 'but only matching modules are emitted. Diagnostics are not filtered ' + 'and may reference modules dropped from output.', collect, ) .option('--pretty', 'Pretty-print JSON output', false) .option( '-q, --quiet', 'Suppress info messages on stderr (warnings and errors still print)', false, ) .addHelpText( 'after', ` Examples: $ svelte-docinfo Analyze current project $ svelte-docinfo ./packages/my-lib Analyze specific directory $ svelte-docinfo -o docs.json Write to file $ svelte-docinfo --source-dir src Non-SvelteKit project (src/ root) $ svelte-docinfo --source-dir src/lib --source-dir src/routes Multiple source dirs (source-root auto-derived as 'src') $ svelte-docinfo --source-dir src/lib --source-dir lib/utils --source-root . No common prefix — module paths stay project-relative $ svelte-docinfo --discovery glob Skip package.json exports $ svelte-docinfo --discovery exports Strict — fail if exports missing $ svelte-docinfo --on-duplicates throw Enforce flat namespace $ svelte-docinfo --only 'components/**' Emit only modules under components/ $ svelte-docinfo --only '*.svelte' Emit only Svelte modules (top-level) $ svelte-docinfo | jq . Pipe to jq for readable output`, ) .action(async (projectRoot: string, raw: OptionValues) => { try { // Validate enum-shaped options before constructing the rest of the options. // Commander accepts arbitrary strings; we narrow to the allowed sets. if (raw.onDuplicates !== undefined && !isOnDuplicatesFlag(raw.onDuplicates)) { throw new Error( `Invalid --on-duplicates value: "${raw.onDuplicates}". ` + `Expected one of: ${ON_DUPLICATES_VALUES.join(', ')}`, ); } if (raw.discovery !== undefined && !isDiscoveryFlag(raw.discovery)) { throw new Error( `Invalid --discovery value: "${raw.discovery}". ` + `Expected one of: ${DISCOVERY_VALUES.join(', ')}`, ); } const options: CliOptions = { include: raw.include, exclude: raw.exclude, output: raw.output, resolveDependencies: raw.resolveDependencies, discovery: raw.discovery, distDir: raw.distDir, sourceDir: raw.sourceDir, sourceRoot: raw.sourceRoot, onDuplicates: raw.onDuplicates, only: raw.only, quiet: raw.quiet, pretty: raw.pretty, }; const resolvedRoot = resolve(projectRoot); // Stderr logger — `--quiet` only mutes info; warnings and errors always print // so silent CI runs still surface actionable signal. const log: AnalysisLog = { info: options.quiet ? () => {} : (msg: string) => console.error(msg), warn: (msg: string) => console.error(`warning: ${msg}`), error: (msg: string) => console.error(`error: ${msg}`), }; // Build sourceOptions only when source-dir or source-root is specified, // so the default (`['src/lib']`) flows through `createSourceOptions`. // `!== undefined` rather than truthy: `--source-root ''` (or `--source-root .`, // aliased internally) is a valid opt-in for project-relative module paths // when sourcePaths share no common prefix. const sourceOptions = options.sourceDir !== undefined || options.sourceRoot !== undefined ? { ...(options.sourceDir !== undefined ? {sourcePaths: options.sourceDir} : {}), ...(options.sourceRoot !== undefined ? {sourceRoot: options.sourceRoot} : {}), } : undefined; const onDuplicates: OnDuplicates | undefined = options.onDuplicates; const {modules, diagnostics} = await analyzeFromFiles({ projectRoot: resolvedRoot, include: options.include, exclude: options.exclude, resolveDependencies: options.resolveDependencies, discovery: options.discovery, distDir: options.distDir, ...(sourceOptions !== undefined ? {sourceOptions} : {}), ...(onDuplicates !== undefined ? {onDuplicates} : {}), log, }); // `--only` is an output-only filter: analysis ran against the full // owned set so re-exports/dependents/`alsoExportedFrom` are correct, // then matching modules are kept for emission. Diagnostics pass // through untouched — filtering them by the same patterns would // silently drop warnings about modules the user excluded from output. const matchOnly = options.only && picomatch(options.only); const emittedModules = matchOnly ? modules.filter((m) => matchOnly(m.path)) : modules; // `AnalyzeResultJson` is the wire-format contract — both `modules` // and `diagnostics` default to `[]` on `.parse()`, so empty arrays // stripped here round-trip losslessly through the schema. Consumers // programmatically ingesting analysis JSON should parse through // `AnalyzeResultJson` to restore defaults; raw-JSON consumers // (`jq '.diagnostics | length'` returns `0` on `{}` since jq treats // null length as 0) don't need the parse step. const jsonOutput = JSON.stringify( {modules: emittedModules, diagnostics}, compactReplacer, options.pretty ? 2 : undefined, ); // Write output. `-o -` is the conventional stdout sentinel // (matches gzip, curl, cat); accept it as an explicit form so // `svelte-docinfo -o "$OUT"` works when $OUT=-. if (options.output && options.output !== '-') { await writeFile(options.output, jsonOutput + '\n'); log.info(`Wrote output to ${options.output}`); } else { console.log(jsonOutput); } exitCode = hasErrors(diagnostics) ? 1 : 0; } catch (error) { // Friendly one-line error for users; full stack only on DEBUG=1 // so CI logs and bug reports can still capture it on demand. const message = error instanceof Error ? error.message : String(error); console.error(`error: ${message}`); if (process.env.DEBUG) { console.error(error); } exitCode = 2; } }); await program.parseAsync(argv); return exitCode; };
{ "path": "cli.ts", "declarations": [ { "name": "CliOptions", "kind": "interface", "docComment": "CLI options parsed from command line arguments.", "typeSignature": "CliOptions", "sourceLine": 51, "members": [ { "name": "include", "kind": "variable", "docComment": "File patterns to include (undefined = use exports discovery or defaults).", "typeSignature": "Array<string>", "optional": true }, { "name": "exclude", "kind": "variable", "docComment": "File patterns to exclude (undefined = use defaults — test and spec files).\n\nWhen provided, **fully replaces** the defaults — no array merge. Passing\na custom `--exclude` pattern drops the default test/spec filters unless\nthe caller re-includes them explicitly.", "typeSignature": "Array<string>", "optional": true }, { "name": "output", "kind": "variable", "docComment": "Output file path (undefined = stdout).", "typeSignature": "string", "optional": true }, { "name": "resolveDependencies", "kind": "variable", "docComment": "Whether to resolve dependencies. Mapped from the `--no-resolve-dependencies`\nflag (commander populates `true` by default; `false` when the flag is passed).\nOptional here so external callers can omit it; treated as `true` when undefined.", "typeSignature": "boolean", "optional": true }, { "name": "discovery", "kind": "variable", "docComment": "Discovery strategy (undefined = 'auto').\n\nMapped from `--discovery <auto|exports|glob>`.", "typeSignature": "Discovery", "optional": true }, { "name": "distDir", "kind": "variable", "docComment": "Dist directory name for exports-based discovery (undefined = 'dist').", "typeSignature": "string", "optional": true }, { "name": "sourceDir", "kind": "variable", "docComment": "Source directories relative to project root (undefined = ['src/lib']).\n\nRepeatable. Drives the implicit include glob in the glob-discovery\nfallback (via `deriveIncludePatterns` inside `discoverSourceFiles`) so\ncustom source directories survive without needing an explicit `--include`.", "typeSignature": "Array<string>", "optional": true }, { "name": "sourceRoot", "kind": "variable", "docComment": "Source root for module path extraction, relative to project root\n(undefined = single sourceDir or longest common prefix).\n\nModule paths in output are stripped of `<projectRoot>/<sourceRoot>/`.\nPass `.` (or `\"\"`) to keep module paths project-relative — useful when\n`sourceDir` entries share no common prefix. The `.` form is normalized\nto `\"\"` inside `normalizeSourceOptions`.", "typeSignature": "string", "optional": true }, { "name": "onDuplicates", "kind": "variable", "docComment": "Behavior when duplicate declaration names are found across modules\n(undefined = emit `duplicate_declaration` diagnostic, no dispatch).\n\nDuplicate detection always runs regardless of this option — the diagnostic\nis the data, this option is the dispatch action.", "typeSignature": "OnDuplicatesFlag", "optional": true }, { "name": "only", "kind": "variable", "docComment": "Glob patterns to filter the emitted `modules` array against `ModuleJson.path`\n(undefined = emit all analyzed modules).\n\nRepeatable. Output-only filter: full-project analysis still runs so re-exports,\ndependents, and `alsoExportedFrom` stay correct against the complete owned set.\nDiagnostics aren't filtered — they may reference modules dropped from output.", "typeSignature": "Array<string>", "optional": true }, { "name": "quiet", "kind": "variable", "docComment": "Whether to suppress info messages to stderr. Treated as `false` when undefined.", "typeSignature": "boolean", "optional": true }, { "name": "pretty", "kind": "variable", "docComment": "Whether to pretty-print JSON output. Treated as `false` when undefined.", "typeSignature": "boolean", "optional": true } ] }, { "name": "runCli", "kind": "function", "docComment": "Run the CLI with the given arguments.", "typeSignature": "(argv?: string[]): Promise<number>", "sourceLine": 125, "parameters": [ { "name": "argv", "type": "string[]", "description": "command line arguments (defaults to `process.argv`)", "defaultValue": "process.argv" } ], "returnType": "Promise<number>", "returnDescription": "exit code: 0 for success, 1 if errors in diagnostics, 2 for CLI errors" } ], "moduleComment": "CLI for svelte-docinfo.\n\nStatic analysis for TypeScript and Svelte source code.\n\n@example\n```bash\n# Analyze current project\nsvelte-docinfo\n\n# Analyze specific directory\nsvelte-docinfo ./packages/my-lib\n\n# Write to file\nsvelte-docinfo --output docs/library.json\n```", "dependencies": [ "analyze-core.ts", "analyze.ts", "declaration-helpers.ts", "diagnostics.ts", "discovery.ts", "log.ts" ], "dependents": [ "main.ts" ] }