diff --git a/.github/skills/fluentui-cli/SKILL.md b/.github/skills/fluentui-cli/SKILL.md new file mode 100644 index 00000000000000..2d26ca35719262 --- /dev/null +++ b/.github/skills/fluentui-cli/SKILL.md @@ -0,0 +1,116 @@ +--- +name: fluentui-cli +description: 'Guides working with @fluentui/cli — the internal Fluent UI command-line tool. Use when asked to add a new CLI command, modify an existing command, understand CLI architecture, debug CLI issues, or work with the CLI build/test workflow. Covers: architecture overview, yargs conventions, lazy-loading pattern, testing, and available Nx generators.' +--- + +# `@fluentui/cli` + +The `@fluentui/cli` package (`tools/cli/`) is the internal Fluent UI command-line tool built with **yargs**. It uses a modular, lazy-loading architecture where each command lives in its own directory and is only loaded at runtime when invoked. + +## Architecture + +### File Structure + +``` +tools/cli/ +├── bin/fluentui-cli.js # Node entry point (requires compiled output) +├── src/ +│ ├── cli.ts # Main yargs setup, registers all commands +│ ├── index.ts # Public API re-exports +│ ├── utils/ +│ │ ├── index.ts # Barrel exports +│ │ └── types.ts # Shared CommandHandler type +│ └── commands/ +│ ├── metadata/ # API metadata command +│ │ ├── index.ts # CommandModule definition +│ │ ├── handler.ts # Lazy-loaded handler +│ │ └── impl/ # Parsing and formatting +│ └── report/ # Reporting command group +│ ├── index.ts # Registers report subcommands +│ ├── commands/ # info and usage definitions +│ └── impl/ # Lazy-loaded report implementations +``` + +### Lazy Loading Pattern + +Command definitions (name, description, options builder) are eagerly imported — they are lightweight. The actual handler logic is **lazy-loaded via dynamic `import()`** only when the command is invoked: + +```typescript +// index.ts — always loaded (lightweight) +handler: async argv => { + // handler.ts only loaded when this specific command runs + const { handler } = await import('./handler'); + return handler(argv); +}, +``` + +This keeps command implementations out of the startup path until the selected command runs. + +### CommandHandler Type + +All handlers use the shared `CommandHandler` type from `src/utils/types.ts`: + +```typescript +import type { ArgumentsCamelCase } from 'yargs'; + +export type CommandHandler = (argv: ArgumentsCamelCase) => Promise; +``` + +### Command Registration in cli.ts + +Each command is imported and registered in `tools/cli/src/cli.ts`: + +```typescript +import yargs from 'yargs'; +import reportCommand from './commands/report'; +import metadataCommand from './commands/metadata'; + +export async function main(argv: string[]): Promise { + await yargs(argv) + .scriptName('fluentui-cli') + .usage('$0 [options]') + .command(reportCommand) + .command(metadataCommand) + .demandCommand(1, 'You need to specify a command to run.') + .help() + .strict() + .parse(); +} +``` + +## Build & Test + +```sh +# Build the CLI +yarn nx run cli:build + +# Run tests +yarn nx run cli:test + +# Test --help output +node tools/cli/bin/fluentui-cli.js --help +node tools/cli/bin/fluentui-cli.js --help +``` + +## Conventions + +- **Always use the Nx generator** to scaffold new commands — do not create command files manually. See the [adding commands](references/adding-commands.md) reference. +- Place shared utilities in `tools/cli/src/utils/` and export through the barrel file. +- Every command must support `--help` (handled by yargs `.help()` in the builder). +- Handler files must export a named `handler` constant typed with `CommandHandler`. +- Tests live adjacent to handlers as `handler.spec.ts`. + +## Common Patterns + +### Subcommands (nested commands) + +If a command needs subcommands, use yargs nested command pattern in the builder: + +```typescript +builder: yargs => + yargs + .command('summary', 'Generate a summary', subBuilder => subBuilder, summaryHandler) + .command('details', 'Generate detailed output', subBuilder => subBuilder, detailsHandler) + .demandCommand(1) + .help(), +``` diff --git a/.github/skills/fluentui-cli/references/adding-commands.md b/.github/skills/fluentui-cli/references/adding-commands.md new file mode 100644 index 00000000000000..ab6f3aac928c18 --- /dev/null +++ b/.github/skills/fluentui-cli/references/adding-commands.md @@ -0,0 +1,135 @@ +# Adding a New CLI Command + +## Step 1 — Scaffold the Command + +Run the `cli-command` Nx generator: + +```sh +yarn nx g @fluentui/workspace-plugin:cli-command --description "" +``` + +### Example + +```sh +yarn nx g @fluentui/workspace-plugin:cli-command analyze --description "Analyze bundle sizes" +``` + +### What Gets Generated + +``` +tools/cli/src/commands// +├── index.ts # Yargs CommandModule definition (lightweight, eagerly loaded) +├── handler.ts # Handler implementation (lazy-loaded via dynamic import) +└── handler.spec.ts # Jest unit tests for the handler +``` + +The generator also **automatically registers** the new command in `tools/cli/src/cli.ts` by: + +1. Adding an import statement for the command module +2. Adding a `.command()` registration call + +Preview what will be generated without writing to disk: + +```sh +yarn nx g @fluentui/workspace-plugin:cli-command --dry-run +``` + +## Step 2 — Implement the Handler + +Open `tools/cli/src/commands//handler.ts` and implement the command logic: + +```typescript +import type { CommandHandler } from '../../utils/types'; + +// Define the shape of your command's arguments +interface AnalyzeArgs { + project?: string; + verbose?: boolean; +} + +export const handler: CommandHandler = async argv => { + const { project, verbose } = argv; + + // Your implementation here + console.log(`Analyzing${project ? ` project: ${project}` : ''}...`); +}; +``` + +## Step 3 — Add Options and Arguments + +Edit `tools/cli/src/commands//index.ts` to add yargs options in the `builder`: + +```typescript +import type { CommandModule } from 'yargs'; + +const command: CommandModule = { + command: 'analyze', + describe: 'Analyze bundle sizes', + builder: yargs => + yargs + .option('project', { + alias: 'p', + type: 'string', + describe: 'Project name to analyze', + }) + .option('verbose', { + type: 'boolean', + default: false, + describe: 'Show detailed output', + }) + .version(false) + .help(), + handler: async argv => { + const { handler } = await import('./handler'); + return handler(argv); + }, +}; + +export default command; +``` + +## Step 4 — Write Tests + +Update `tools/cli/src/commands//handler.spec.ts` with meaningful tests: + +```typescript +import { handler } from './handler'; + +describe('analyze handler', () => { + let logSpy: jest.SpyInstance; + + beforeEach(() => { + logSpy = jest.spyOn(console, 'log').mockImplementation(); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + it('should analyze all projects when no project specified', async () => { + await handler({ _: ['analyze'], $0: 'fluentui-cli' }); + + expect(logSpy).toHaveBeenCalledWith('Analyzing...'); + }); + + it('should analyze specific project when specified', async () => { + await handler({ _: ['analyze'], $0: 'fluentui-cli', project: 'react-button' }); + + expect(logSpy).toHaveBeenCalledWith('Analyzing project: react-button...'); + }); +}); +``` + +## Step 5 — Verify + +```sh +# Build the CLI +yarn nx run cli:build + +# Run tests +yarn nx run cli:test + +# Test --help output +node tools/cli/bin/fluentui-cli.js --help +node tools/cli/bin/fluentui-cli.js --help +``` diff --git a/change/@fluentui-cli-1d82ac6b-1cef-4156-892b-832d275398da.json b/change/@fluentui-cli-1d82ac6b-1cef-4156-892b-832d275398da.json new file mode 100644 index 00000000000000..c16d1efd738bd7 --- /dev/null +++ b/change/@fluentui-cli-1d82ac6b-1cef-4156-892b-832d275398da.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "initial release", + "packageName": "@fluentui/cli", + "email": "martinhochel@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/tools/cli/.swcrc b/tools/cli/.swcrc new file mode 100644 index 00000000000000..adf80780a17cce --- /dev/null +++ b/tools/cli/.swcrc @@ -0,0 +1,30 @@ +{ + "jsc": { + "target": "es2020", + "parser": { + "syntax": "typescript", + "decorators": true, + "dynamicImport": true + }, + "transform": { + "decoratorMetadata": true, + "legacyDecorator": true + }, + "keepClassNames": true, + "externalHelpers": true, + "loose": true + }, + "module": { + "type": "commonjs" + }, + "sourceMaps": true, + "exclude": [ + "jest.config.ts", + "__fixtures__", + ".*\\.spec.tsx?$", + ".*\\.test.tsx?$", + "./src/jest-setup.ts$", + "./**/jest-setup.ts$", + ".*.js$" + ] +} diff --git a/tools/cli/README.md b/tools/cli/README.md new file mode 100644 index 00000000000000..efdc30f056684b --- /dev/null +++ b/tools/cli/README.md @@ -0,0 +1,45 @@ +# @fluentui/cli + +Command-line tool for Fluent UI usage reporting and API metadata extraction. + +> **Preview** — APIs and commands may change without notice. + +## Usage + +```sh +npx @fluentui/cli [options] +``` + +> Run any command with `--help` for detailed options. + +## Commands + +### `report` + +Generate reports for issue filing or codebase analysis. + +```sh +# Quick environment/package summary for issue reporting +npx @fluentui/cli report info + +# Deep codebase usage analysis of Fluent UI APIs +npx @fluentui/cli report usage --path ./src --reporter markdown --output report.md +``` + +### `metadata` + +Extract API metadata from package `.d.ts` build output. + +```sh +npx @fluentui/cli metadata --entry dist/index.d.ts --reporter json +``` + +## Development + +```sh +# Build +yarn nx run cli:build + +# Test +yarn nx run cli:test +``` diff --git a/tools/cli/bin/fluentui-cli.js b/tools/cli/bin/fluentui-cli.js new file mode 100755 index 00000000000000..e7b64bac11d43c --- /dev/null +++ b/tools/cli/bin/fluentui-cli.js @@ -0,0 +1,7 @@ +#!/usr/bin/env node + +// @ts-check + +const { main } = require('../dist/src/cli'); + +main(process.argv.slice(2)); diff --git a/tools/cli/eslint.config.cjs b/tools/cli/eslint.config.cjs new file mode 100644 index 00000000000000..30f116f219537c --- /dev/null +++ b/tools/cli/eslint.config.cjs @@ -0,0 +1,11 @@ +// @ts-check +const fluentPlugin = require('@fluentui/eslint-plugin'); +const nodeConfig = fluentPlugin.configs['flat/node']; + +/** @type {import("eslint").Linter.Config[]} */ +module.exports = [ + { + ignores: ['src/**/__fixtures__/**'], + }, + ...(Array.isArray(nodeConfig) ? nodeConfig : [nodeConfig]), +]; diff --git a/tools/cli/jest.config.ts b/tools/cli/jest.config.ts new file mode 100644 index 00000000000000..2fd082c49cfac4 --- /dev/null +++ b/tools/cli/jest.config.ts @@ -0,0 +1,29 @@ +/* eslint-disable */ +import { readFileSync } from 'node:fs'; +const { join } = require('node:path'); + +// Reading the SWC compilation config and remove the "exclude" +// for the test files to be compiled by SWC +const { exclude: _, ...swcJestConfig } = JSON.parse(readFileSync(join(__dirname, '.swcrc'), 'utf-8')); + +// disable .swcrc look-up by SWC core because we're passing in swcJestConfig ourselves. +// If we do not disable this, SWC Core will read .swcrc and won't transform our test files due to "exclude" +if (swcJestConfig.swcrc === undefined) { + swcJestConfig.swcrc = false; +} + +// Uncomment if using global setup/teardown files being transformed via swc +// https://nx.dev/nx-api/jest/documents/overview#global-setupteardown-with-nx-libraries +// jest needs EsModule Interop to find the default exported setup/teardown functions +// swcJestConfig.module.noInterop = false; + +export default { + displayName: 'cli', + preset: '../../jest.preset.js', + transform: { + '^.+\\.tsx?$': ['@swc/jest', swcJestConfig], + }, + moduleFileExtensions: ['ts', 'tsx', 'js'], + testEnvironment: 'node', + coverageDirectory: '../../coverage/tools/cli', +}; diff --git a/tools/cli/package.json b/tools/cli/package.json new file mode 100644 index 00000000000000..feb02049ab2e70 --- /dev/null +++ b/tools/cli/package.json @@ -0,0 +1,31 @@ +{ + "name": "@fluentui/cli", + "version": "0.0.0", + "type": "commonjs", + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "bin": { + "cli": "./bin/fluentui-cli.js", + "fluentui-cli": "./bin/fluentui-cli.js" + }, + "files": [ + "*.md", + "bin", + "dist" + ], + "dependencies": { + "@swc/helpers": "^0.5.1", + "fast-glob": "^3.3.3", + "ts-morph": "^23.0.0", + "yargs": "^17.7.2" + }, + "devDependencies": { + "@types/yargs": "^17.0.33" + }, + "beachball": { + "disallowedChangeTypes": [ + "major", + "prerelease" + ] + } +} diff --git a/tools/cli/project.json b/tools/cli/project.json new file mode 100644 index 00000000000000..3e021154c09777 --- /dev/null +++ b/tools/cli/project.json @@ -0,0 +1,18 @@ +{ + "name": "cli", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "tools/cli/src", + "projectType": "library", + "tags": ["platform:node", "tools"], + "targets": { + "build": { + "executor": "@nx/js:swc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "{projectRoot}/dist", + "main": "tools/cli/src/index.ts", + "tsConfig": "tools/cli/tsconfig.lib.json" + } + } + } +} diff --git a/tools/cli/src/cli.ts b/tools/cli/src/cli.ts new file mode 100644 index 00000000000000..a34461adb521cb --- /dev/null +++ b/tools/cli/src/cli.ts @@ -0,0 +1,35 @@ +import yargs from 'yargs'; + +import reportCommand from './commands/report'; +import metadataCommand from './commands/metadata'; + +const BANNER = ` + ███████╗██╗ ██╗ ██╗███████╗███╗ ██╗████████╗ ██╗ ██╗██╗ + ██╔════╝██║ ██║ ██║██╔════╝████╗ ██║╚══██╔══╝ ██║ ██║██║ + █████╗ ██║ ██║ ██║█████╗ ██╔██╗ ██║ ██║ ██║ ██║██║ + ██╔══╝ ██║ ██║ ██║██╔══╝ ██║╚██╗██║ ██║ ██║ ██║██║ + ██║ ███████╗╚██████╔╝███████╗██║ ╚████║ ██║ ╚██████╔╝██║ + ╚═╝ ╚══════╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚═════╝ ╚═╝ + CLI +`; + +export async function main(argv: string[]): Promise { + await yargs(argv) + .scriptName('fluentui-cli') + .usage(`${BANNER}\n $0 [options]`) + .command(reportCommand) + .command(metadataCommand) + .demandCommand(1, 'You need to specify a command to run.') + .help() + .strict() + .fail((message, error, yargsInstance) => { + if (error) { + console.error(error.message); + } else if (message) { + yargsInstance.showHelp(); + console.error(`\n${message}`); + } + process.exit(1); + }) + .parse(); +} diff --git a/tools/cli/src/commands/metadata/README.md b/tools/cli/src/commands/metadata/README.md new file mode 100644 index 00000000000000..dab24ce98964a8 --- /dev/null +++ b/tools/cli/src/commands/metadata/README.md @@ -0,0 +1,146 @@ +# `metadata` command + +The `metadata` command extracts the full public API surface from a Fluent UI package's `.d.ts` build output and produces structured metadata. It is similar in spirit to [react-docgen-typescript](https://github.com/styleguidist/react-docgen-typescript) but covers **all exports** — components, hooks, types, and utilities — with full type signatures and JSDoc documentation. + +## Usage + +```bash +fluentui metadata [--entry ] [--reporter json|markdown|html] [--output ] +``` + +| Flag | Alias | Default | Description | +| ------------ | ----- | ---------------------------- | ----------------------------------------------- | +| `--entry` | `-e` | resolved from `package.json` | Path to `.d.ts` entry file or package directory | +| `--reporter` | `-r` | `json` | Output format: `json`, `markdown`, or `html` | +| `--output` | `-o` | stdout | Output file path | + +### Entry resolution + +By default the command reads the closest `package.json`, looks for the `"types"` (or `"typings"`) field, and resolves the `.d.ts` entry file. The `--entry` flag accepts either: + +- A direct path to an `index.d.ts` file +- A directory containing a `package.json` (the types field is read from it) + +> **Prerequisite**: The package must be built first (`yarn nx run :build`) so that `.d.ts` output exists. + +## Categories + +Every exported symbol is classified into one of four categories: + +| Category | Criteria | +| -------------- | --------------------------------------------------------------------------------------- | +| **Components** | `ForwardRefComponent<>`, `React.FC<>`, functions returning JSX, PascalCase + JSX return | +| **Hooks** | `use*` naming convention (functions starting with `use` + uppercase letter) | +| **Types** | Interfaces, type aliases, enums | +| **Others** | Constants, render functions, utility functions, class-name objects | + +Within each category, symbols are further grouped by annotation: + +| Group | JSDoc tags | +| -------------- | ------------------ | +| **Stable** | _(no special tag)_ | +| **Deprecated** | `@deprecated` | +| **Internal** | `@internal` | +| **Preview** | `@alpha`, `@beta` | + +## Output formats + +- **JSON** — machine-readable metadata with `package`, `legend`, `categories`, and `externalReferences`. Default. +- **Markdown** — collapsible sections per category with summary tables, annotation sub-groups, and clickable `$ref` links. +- **HTML** — self-contained report with collapsible categories, annotation sub-groups with colored borders, clickable anchor links, and dark-mode support. + +### JSON schema overview + +```jsonc +{ + "package": { "name": "@fluentui/react-button", "version": "9.8.2" }, + "legend": { + /* category descriptions */ + }, + "categories": { + "components": { + "Button": { + "name": "Button", + "description": "Buttons give people a way to trigger an action.", + "typeSignature": "ForwardRefComponent", + "tags": {}, + "propsType": { "$ref": "#/categories/types/ButtonProps" } + } + }, + "hooks": { + /* ... */ + }, + "types": { + /* ... */ + }, + "others": { + /* ... */ + } + }, + "externalReferences": { + "@fluentui/react-utilities": { + "metadataRef": "@fluentui/react-utilities/metadata.json", + "symbols": { + "ForwardRefComponent": { "$ref": "@fluentui/react-utilities#/categories/types/ForwardRefComponent" }, + "Slot": { "$ref": "@fluentui/react-utilities#/categories/types/Slot" } + } + } + } +} +``` + +### Cross-package references (`$ref`) + +- **Within the same package**: JSON Pointer — `{ "$ref": "#/categories/types/ButtonSlots" }` +- **Across packages**: URI-style — `{ "$ref": "@fluentui/react-utilities#/categories/types/ComponentProps" }` + +When a dependency does not have a `metadata.json`, the symbol falls back to `{ "inline": "SymbolName" }`. + +## Architecture + +``` +metadata/ +├── index.ts # Yargs command definition (--entry, --reporter, --output) +├── handler.ts # Main handler — orchestrates resolve → parse → refs → format → output +├── handler.spec.ts # Integration tests +├── impl/ +│ ├── types.ts # All TypeScript interfaces (MetadataOutput, *Doc, ExternalPackageRef) +│ ├── entry-resolver.ts # Resolves .d.ts from package.json or --entry flag +│ ├── entry-resolver.spec.ts # Entry resolution tests +│ ├── dts-parser.ts # ts-morph parser — extracts all exports with full type signatures +│ ├── dts-parser.spec.ts # Parser tests (classification, JSDoc, members, params) +│ ├── cross-package-resolver.ts # Loads dependency metadata.json and builds $ref URIs +│ ├── cross-package-resolver.spec.ts +│ ├── annotation-groups.ts # Groups symbols by @deprecated, @internal, @alpha, @beta +│ ├── annotation-groups.spec.ts +│ ├── markdown-formatter.ts # Markdown output with collapsible sections and ref links +│ └── html-formatter.ts # Self-contained HTML output with dark mode +└── __fixtures__/ # Test fixtures (.d.ts, package.json) +``` + +### How it works + +1. **Entry resolution** — finds the `.d.ts` entry file from `package.json` types/typings field or `--entry` flag +2. **Parsing** — ts-morph loads the `.d.ts`, iterates `getExportedDeclarations()`, classifies each symbol, extracts JSDoc, type signatures, members, parameters, and return types +3. **Cross-package resolution** — for each imported external package, checks for `metadata.json` and builds `$ref` pointers; scans exported type signatures to determine which external symbols are actually used in the public API +4. **Annotation grouping** — symbols within each category are bucketed by `@deprecated` / `@internal` / `@alpha` / `@beta` tags +5. **Formatting** — routes to JSON, markdown, or HTML formatter +6. **Output** — prints to stdout or writes to file via `--output` + +### Key implementation details + +- **JSDoc on `declare const`**: In `.d.ts` files, JSDoc lives on the parent `VariableStatement`, not the `VariableDeclaration` node. The parser's `getJsDocTarget()` helper walks up to find it. +- **Function-typed variables**: `declare const renderButton: (state: S) => JSX.Element` is classified as `kind: 'function'` (not `'variable'`) by checking for call signatures on the type. +- **Props type extraction**: `ForwardRefExoticComponent>` is parsed with a regex to extract the first type argument as the props type reference. + +## Testing + +```bash +# Run all metadata tests +yarn nx run cli:test -- --testPathPatterns=metadata + +# Run full CLI test suite +yarn nx run cli:test +``` + +Tests use a fixture-based approach with `__fixtures__/sample-button.d.ts` containing components, hooks, types, enums, and external imports to exercise all classification paths and formatter output. diff --git a/tools/cli/src/commands/metadata/__fixtures__/package.json b/tools/cli/src/commands/metadata/__fixtures__/package.json new file mode 100644 index 00000000000000..c8104e7482b30e --- /dev/null +++ b/tools/cli/src/commands/metadata/__fixtures__/package.json @@ -0,0 +1,5 @@ +{ + "name": "@fluentui/sample-button", + "version": "1.0.0", + "types": "./sample-button.d.ts" +} diff --git a/tools/cli/src/commands/metadata/__fixtures__/sample-button.d.ts b/tools/cli/src/commands/metadata/__fixtures__/sample-button.d.ts new file mode 100644 index 00000000000000..e1760e71d84337 --- /dev/null +++ b/tools/cli/src/commands/metadata/__fixtures__/sample-button.d.ts @@ -0,0 +1,144 @@ +import * as React from 'react'; +import type { Slot } from '@sample/utilities'; +import type { SlotClassNames } from '@sample/utilities'; + +/** + * Props for the SampleButton component. + */ +export declare interface SampleButtonProps { + /** + * The visual style of the button. + * + * @default 'secondary' + */ + appearance?: 'primary' | 'secondary' | 'outline'; + /** + * Whether the button is disabled. + * + * @default false + */ + disabled?: boolean; + /** The size of the button. */ + size?: 'small' | 'medium' | 'large'; +} + +/** + * State for the SampleButton component. + */ +export declare interface SampleButtonState { + appearance: 'primary' | 'secondary' | 'outline'; + disabled: boolean; +} + +/** + * Slots for the SampleButton component. + */ +export declare type SampleButtonSlots = { + /** Root element of the button. */ + root: Slot; + /** Optional icon slot. */ + icon?: Slot; +}; + +/** + * SampleButton gives people a way to trigger an action. + */ +export declare const SampleButton: React.ForwardRefExoticComponent< + SampleButtonProps & React.RefAttributes +>; + +export declare const sampleButtonClassNames: SlotClassNames; + +export declare const getPartitionedProps: ({ + props, + excluded, +}: { + props: SampleButtonProps; + excluded?: string[]; +}) => Record; + +/** + * Hook to create SampleButton state. + * @param props - User provided props to the SampleButton component. + * @param ref - User provided ref. + */ +export declare const useSampleButton_unstable: ( + props: SampleButtonProps, + ref: React.Ref, +) => SampleButtonState; + +export declare const useSampleButtonStyles_unstable: (state: SampleButtonState) => SampleButtonState; + +/** + * Renders SampleButton from state. + */ +export declare const renderSampleButton_unstable: (state: SampleButtonState) => JSX.Element; + +/** + * @internal + * Internal context value. + */ +export declare interface SampleButtonContextValue { + size?: 'small' | 'medium' | 'large'; +} + +/** + * Selection methods interface with method signatures. + */ +export declare interface SampleSelectionMethods { + selectItem(id: string): void; + isSelected(id: string): boolean; +} + +/** + * Size options for the button. + */ +export declare type SampleButtonSize = 'small' | 'medium' | 'large'; + +/** + * Options for controllable state. + */ +export declare type SampleStateOptions = { + /** + * User-provided default state. + */ + defaultState?: string; + /** Current state value. */ + state: string | undefined; +}; + +/** + * @deprecated Use SampleButtonSize instead. + */ +export declare enum ButtonVariant { + Primary = 'primary', + Secondary = 'secondary', +} + +export declare function useToggleState(props: SampleButtonProps): SampleButtonState; + +/** + * Gets the trigger child from children. + * @internal + */ +export declare function getTriggerChild(children: React.ReactNode): React.ReactElement | null; + +/** + * A PascalCase function component declared with `function` keyword. + */ +export declare function PascalCaseComponent(props: { label: string }): JSX.Element; + +/** + * Checks if an element is an HTML element. + */ +export declare function isHTMLElement( + element?: unknown, + options?: { + /** + * Can be used to provide a custom constructor name. + */ + constructorName?: string; + }, +): boolean; + +export {}; diff --git a/tools/cli/src/commands/metadata/handler.spec.ts b/tools/cli/src/commands/metadata/handler.spec.ts new file mode 100644 index 00000000000000..a9fe83b3dbe0c8 --- /dev/null +++ b/tools/cli/src/commands/metadata/handler.spec.ts @@ -0,0 +1,97 @@ +import * as path from 'node:path'; +import * as fs from 'node:fs'; + +import { handler } from './handler'; + +const FIXTURES_DIR = path.resolve(__dirname, '__fixtures__'); +const SAMPLE_DTS = path.join(FIXTURES_DIR, 'sample-button.d.ts'); + +describe('metadata handler', () => { + let logSpy: jest.SpyInstance; + + beforeEach(() => { + logSpy = jest.spyOn(console, 'log').mockImplementation(); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + it('should output JSON metadata for a .d.ts entry file', async () => { + await handler({ _: ['metadata'], $0: 'fluentui-cli', entry: SAMPLE_DTS, reporter: 'json' }); + + expect(logSpy).toHaveBeenCalledTimes(1); + const output = JSON.parse(logSpy.mock.calls[0][0]); + + expect(output.package.name).toBe('@fluentui/sample-button'); + expect(output.categories.components).toHaveProperty('SampleButton'); + expect(output.categories.hooks).toHaveProperty('useSampleButton_unstable'); + expect(output.categories.types).toHaveProperty('SampleButtonProps'); + expect(output.categories.others).toHaveProperty('sampleButtonClassNames'); + }); + + it('should output markdown when reporter=markdown', async () => { + await handler({ _: ['metadata'], $0: 'fluentui-cli', entry: SAMPLE_DTS, reporter: 'markdown' }); + + const output: string = logSpy.mock.calls[0][0]; + expect(output).toContain('# API Metadata:'); + expect(output).toContain('Components ('); + expect(output).toContain('SampleButton'); + }); + + it('should output HTML when reporter=html', async () => { + await handler({ _: ['metadata'], $0: 'fluentui-cli', entry: SAMPLE_DTS, reporter: 'html' }); + + const output: string = logSpy.mock.calls[0][0]; + expect(output).toContain(''); + expect(output).toContain('SampleButton'); + }); + + it('should include externalReferences for named imports used in API surface', async () => { + await handler({ _: ['metadata'], $0: 'fluentui-cli', entry: SAMPLE_DTS, reporter: 'json' }); + + const output = JSON.parse(logSpy.mock.calls[0][0]); + + // The fixture imports Slot and SlotClassNames from @sample/utilities + // Both are used in type signatures (SampleButtonSlots uses Slot, sampleButtonClassNames uses SlotClassNames) + expect(output.externalReferences).toBeDefined(); + expect(output.externalReferences['@sample/utilities']).toBeDefined(); + + const utilsRef = output.externalReferences['@sample/utilities']; + expect(utilsRef.metadataRef).toBe('@sample/utilities/metadata.json'); + expect(utilsRef.symbols).toHaveProperty('Slot'); + expect(utilsRef.symbols).toHaveProperty('SlotClassNames'); + }); + + it('should include external references in markdown output', async () => { + await handler({ _: ['metadata'], $0: 'fluentui-cli', entry: SAMPLE_DTS, reporter: 'markdown' }); + + const output: string = logSpy.mock.calls[0][0]; + expect(output).toContain('External References'); + expect(output).toContain('@sample/utilities'); + }); + + it('should include external references in HTML output', async () => { + await handler({ _: ['metadata'], $0: 'fluentui-cli', entry: SAMPLE_DTS, reporter: 'html' }); + + const output: string = logSpy.mock.calls[0][0]; + expect(output).toContain('External References'); + expect(output).toContain('@sample/utilities'); + }); + + it('should write to file when --output is specified', async () => { + const tmpOutput = path.join(FIXTURES_DIR, '__test-output__.json'); + + try { + await handler({ _: ['metadata'], $0: 'fluentui-cli', entry: SAMPLE_DTS, reporter: 'json', output: tmpOutput }); + + expect(fs.existsSync(tmpOutput)).toBe(true); + const content = JSON.parse(fs.readFileSync(tmpOutput, 'utf-8')); + expect(content.package.name).toBe('@fluentui/sample-button'); + } finally { + if (fs.existsSync(tmpOutput)) { + fs.unlinkSync(tmpOutput); + } + } + }); +}); diff --git a/tools/cli/src/commands/metadata/handler.ts b/tools/cli/src/commands/metadata/handler.ts new file mode 100644 index 00000000000000..a90f2449602ad0 --- /dev/null +++ b/tools/cli/src/commands/metadata/handler.ts @@ -0,0 +1,216 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import type { CommandHandler } from '../../utils/types'; +import type { MetadataArgs, MetadataOutput, ExternalPackageRef, RefOrInline } from './impl/types'; +import type { ParseResult } from './impl/dts-parser'; +import { resolveEntry, readPackageInfo } from './impl/entry-resolver'; +import { parseDtsEntry } from './impl/dts-parser'; +import { loadDependencyMetadata, buildCrossPackageRef } from './impl/cross-package-resolver'; +import { formatMetadataAsMarkdown } from './impl/markdown-formatter'; +import { formatMetadataAsHtml } from './impl/html-formatter'; + +const LEGEND = { + components: { name: 'Components', description: 'React components (ForwardRef, FC, class)' }, + hooks: { name: 'Hooks', description: 'React hooks (use* convention)' }, + types: { name: 'Types', description: 'Interfaces, type aliases, and enums' }, + others: { name: 'Others', description: 'Constants, render functions, and utilities' }, +}; + +export const handler: CommandHandler = async argv => { + const { entry, reporter = 'json', output } = argv; + + // 1. Resolve entry .d.ts + const entryPath = resolveEntry(entry); + const packageInfo = readPackageInfo(path.dirname(entryPath)); + + // 2. Parse the .d.ts + const parseResult = parseDtsEntry(entryPath); + + // 3. Resolve cross-package $refs + const cwd = process.cwd(); + const depMetadataCache = new Map(); + + for (const pkgSpec of parseResult.importedPackages) { + if (!depMetadataCache.has(pkgSpec)) { + depMetadataCache.set(pkgSpec, loadDependencyMetadata(pkgSpec, cwd)); + } + } + + // Enhance component propsType refs with cross-package resolution + for (const comp of Object.values(parseResult.components)) { + if (comp.propsType && '$ref' in comp.propsType) { + const localRef = comp.propsType.$ref; + const symbolName = localRef.split('/').pop()!; + + // If the type isn't in our own types, check dependencies + if (!(symbolName in parseResult.types)) { + for (const [pkgSpec, depMetadata] of depMetadataCache) { + if (depMetadata) { + const crossRef = buildCrossPackageRef(pkgSpec, symbolName, depMetadata); + if (crossRef) { + comp.propsType = crossRef; + break; + } + } + } + } + } + } + + // 4. Build external references + const externalReferences = buildExternalReferences(parseResult, depMetadataCache); + + // 5. Assemble output + const metadataOutput: MetadataOutput = { + package: packageInfo, + legend: LEGEND, + categories: { + components: parseResult.components, + hooks: parseResult.hooks, + types: parseResult.types, + others: parseResult.others, + }, + }; + + if (Object.keys(externalReferences).length > 0) { + metadataOutput.externalReferences = externalReferences; + } + + // 6. Format + let formatted: string; + switch (reporter) { + case 'markdown': + formatted = formatMetadataAsMarkdown(metadataOutput); + break; + case 'html': + formatted = formatMetadataAsHtml(metadataOutput); + break; + case 'json': + default: + formatted = JSON.stringify(metadataOutput, null, 2); + break; + } + + // 7. Output + if (output) { + const outputPath = path.resolve(cwd, output); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, formatted, 'utf-8'); + console.log(`Metadata written to ${outputPath}`); + } else { + console.log(formatted); + } +}; + +// --------------------------------------------------------------------------- +// External references +// --------------------------------------------------------------------------- + +/** + * Collect all type strings from the parsed API surface. + */ +function collectAllTypeStrings(parseResult: ParseResult): string[] { + const strings: string[] = []; + + for (const comp of Object.values(parseResult.components)) { + strings.push(comp.typeSignature); + } + + for (const hook of Object.values(parseResult.hooks)) { + strings.push(hook.typeSignature, hook.returnType); + for (const p of hook.parameters) { + strings.push(p.type); + } + } + + for (const type of Object.values(parseResult.types)) { + strings.push(type.typeSignature); + for (const m of Object.values(type.members)) { + strings.push(m.type); + } + } + + for (const other of Object.values(parseResult.others)) { + strings.push(other.typeSignature); + if (other.parameters) { + for (const p of other.parameters) { + strings.push(p.type); + } + } + if (other.returnType) { + strings.push(other.returnType); + } + } + + return strings; +} + +/** + * Determine which imported external symbols are actually referenced + * in the public API surface, and build the externalReferences map. + */ +function buildExternalReferences( + parseResult: ParseResult, + depMetadataCache: Map, +): Record { + const allTypeStrings = collectAllTypeStrings(parseResult); + const joinedTypes = allTypeStrings.join('\n'); + + // Collect all locally-defined symbol names to exclude from external detection + const localSymbols = new Set([ + ...Object.keys(parseResult.components), + ...Object.keys(parseResult.hooks), + ...Object.keys(parseResult.types), + ...Object.keys(parseResult.others), + ]); + + const result: Record = {}; + + for (const [pkgSpec, importedNames] of parseResult.importedSymbols) { + const usedSymbols: Record = {}; + const depMetadata = depMetadataCache.get(pkgSpec) ?? null; + + for (const symbolName of importedNames) { + // Skip symbols that are defined locally (re-declared in this package) + if (localSymbols.has(symbolName)) { + continue; + } + + // Check if this symbol name appears in any type signature + if (!isSymbolReferenced(symbolName, joinedTypes)) { + continue; + } + + // Build ref or inline fallback + if (depMetadata) { + const ref = buildCrossPackageRef(pkgSpec, symbolName, depMetadata); + if (ref) { + usedSymbols[symbolName] = ref; + continue; + } + } + + usedSymbols[symbolName] = { inline: symbolName }; + } + + if (Object.keys(usedSymbols).length > 0) { + result[pkgSpec] = { + metadataRef: `${pkgSpec}/metadata.json`, + symbols: usedSymbols, + }; + } + } + + return result; +} + +/** + * Check if a symbol name is referenced in the joined type strings. + * Uses word-boundary matching to avoid false positives from substrings. + */ +function isSymbolReferenced(symbolName: string, joinedTypes: string): boolean { + const escaped = symbolName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = new RegExp(`(? = {}): BaseSymbolDoc { + return { name, description: '', typeSignature: '', tags }; +} + +describe('groupByAnnotation', () => { + it('should place symbols without special tags into stable', () => { + const result = groupByAnnotation([sym('Foo'), sym('Bar')]); + expect(result).toHaveLength(1); + expect(result[0].key).toBe('stable'); + expect(result[0].items.map(i => i.name)).toEqual(['Bar', 'Foo']); + }); + + it('should group @deprecated symbols separately', () => { + const result = groupByAnnotation([sym('A'), sym('B', { deprecated: 'use C' }), sym('C')]); + expect(result).toHaveLength(2); + expect(result[0].key).toBe('stable'); + expect(result[0].items.map(i => i.name)).toEqual(['A', 'C']); + expect(result[1].key).toBe('deprecated'); + expect(result[1].items.map(i => i.name)).toEqual(['B']); + }); + + it('should group @internal symbols separately', () => { + const result = groupByAnnotation([sym('Public'), sym('Secret', { internal: '' })]); + expect(result).toHaveLength(2); + expect(result[0].key).toBe('stable'); + expect(result[1].key).toBe('internal'); + expect(result[1].items[0].name).toBe('Secret'); + }); + + it('should group @alpha and @beta into preview', () => { + const result = groupByAnnotation([sym('A', { alpha: '' }), sym('B', { beta: '' }), sym('C')]); + expect(result.find(g => g.key === 'preview')!.items).toHaveLength(2); + expect(result.find(g => g.key === 'stable')!.items).toHaveLength(1); + }); + + it('should use first matching group for symbols with multiple tags', () => { + const result = groupByAnnotation([sym('X', { deprecated: '', internal: '' })]); + expect(result).toHaveLength(1); + // deprecated comes before internal in ANNOTATION_GROUPS + expect(result[0].key).toBe('deprecated'); + }); + + it('should omit empty groups', () => { + const result = groupByAnnotation([sym('A', { internal: '' })]); + expect(result).toHaveLength(1); + expect(result[0].key).toBe('internal'); + }); +}); diff --git a/tools/cli/src/commands/metadata/impl/annotation-groups.ts b/tools/cli/src/commands/metadata/impl/annotation-groups.ts new file mode 100644 index 00000000000000..ceee361ab86ed2 --- /dev/null +++ b/tools/cli/src/commands/metadata/impl/annotation-groups.ts @@ -0,0 +1,52 @@ +import type { BaseSymbolDoc } from './types'; + +/** + * Known annotation groups in display order. + * Symbols are bucketed into the first matching group. + */ +export const ANNOTATION_GROUPS = [ + { key: 'stable', label: 'Stable', tags: [] as string[] }, + { key: 'deprecated', label: 'Deprecated', tags: ['deprecated'] }, + { key: 'internal', label: 'Internal', tags: ['internal'] }, + { key: 'preview', label: 'Preview', tags: ['alpha', 'beta'] }, +] as const; + +export type AnnotationGroupKey = (typeof ANNOTATION_GROUPS)[number]['key']; + +export interface AnnotationGroup { + key: AnnotationGroupKey; + label: string; + items: T[]; +} + +/** + * Group an array of symbol docs by their annotation tags. + * Returns only non-empty groups in display order. + */ +export function groupByAnnotation(symbols: T[]): AnnotationGroup[] { + const buckets = new Map(ANNOTATION_GROUPS.map(g => [g.key, []])); + + for (const sym of symbols) { + const group = resolveGroup(sym.tags); + buckets.get(group)!.push(sym); + } + + return ANNOTATION_GROUPS.filter(g => buckets.get(g.key)!.length > 0).map(g => ({ + key: g.key, + label: g.label, + items: buckets.get(g.key)!.sort((a, b) => a.name.localeCompare(b.name)), + })); +} + +function resolveGroup(tags: Record): AnnotationGroupKey { + // Check non-stable groups in order; first match wins + for (const group of ANNOTATION_GROUPS) { + if (group.key === 'stable') { + continue; + } + if (group.tags.some(t => t in tags)) { + return group.key; + } + } + return 'stable'; +} diff --git a/tools/cli/src/commands/metadata/impl/cross-package-resolver.spec.ts b/tools/cli/src/commands/metadata/impl/cross-package-resolver.spec.ts new file mode 100644 index 00000000000000..0c4a97924860c4 --- /dev/null +++ b/tools/cli/src/commands/metadata/impl/cross-package-resolver.spec.ts @@ -0,0 +1,60 @@ +import type { MetadataOutput } from './types'; +import { loadDependencyMetadata, buildCrossPackageRef } from './cross-package-resolver'; + +describe('cross-package-resolver', () => { + const mockMetadata: MetadataOutput = { + package: { name: '@fluentui/react-utilities', version: '1.0.0' }, + legend: {}, + categories: { + components: {}, + hooks: {}, + types: { + ComponentProps: { + name: 'ComponentProps', + description: 'Base component props type.', + typeSignature: '...', + tags: {}, + kind: 'type-alias', + members: {}, + }, + }, + others: { + slot: { + name: 'slot', + description: 'Slot utility.', + typeSignature: '...', + tags: {}, + kind: 'function', + }, + }, + }, + }; + + describe('buildCrossPackageRef', () => { + it('should return a $ref when the symbol exists in types', () => { + const result = buildCrossPackageRef('@fluentui/react-utilities', 'ComponentProps', mockMetadata); + + expect(result).toEqual({ $ref: '@fluentui/react-utilities#/categories/types/ComponentProps' }); + }); + + it('should return a $ref when the symbol exists in others', () => { + const result = buildCrossPackageRef('@fluentui/react-utilities', 'slot', mockMetadata); + + expect(result).toEqual({ $ref: '@fluentui/react-utilities#/categories/others/slot' }); + }); + + it('should return null when the symbol does not exist', () => { + const result = buildCrossPackageRef('@fluentui/react-utilities', 'NonExistent', mockMetadata); + + expect(result).toBeNull(); + }); + }); + + describe('loadDependencyMetadata', () => { + it('should return null for a non-existent package', () => { + const result = loadDependencyMetadata('__non_existent_package__', '/'); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/tools/cli/src/commands/metadata/impl/cross-package-resolver.ts b/tools/cli/src/commands/metadata/impl/cross-package-resolver.ts new file mode 100644 index 00000000000000..df4b15d5b21d65 --- /dev/null +++ b/tools/cli/src/commands/metadata/impl/cross-package-resolver.ts @@ -0,0 +1,69 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import type { MetadataOutput, RefOrInline } from './types'; + +/** + * Attempt to load a pre-existing metadata.json for a given package. + * Looks in the package's root directory (resolved from node_modules). + * + * @returns The parsed MetadataOutput, or null if not found. + */ +export function loadDependencyMetadata(packageSpecifier: string, cwd: string = process.cwd()): MetadataOutput | null { + const packageDir = resolvePackageDir(packageSpecifier, cwd); + if (!packageDir) { + return null; + } + + const metadataPath = path.join(packageDir, 'metadata.json'); + if (!fs.existsSync(metadataPath)) { + return null; + } + + try { + return JSON.parse(fs.readFileSync(metadataPath, 'utf-8')) as MetadataOutput; + } catch { + return null; + } +} + +/** + * Build a cross-package `$ref` for a symbol in a dependency that has metadata.json. + * + * @returns A $ref like `"@fluentui/react-utilities#/categories/types/ComponentProps"`, or null. + */ +export function buildCrossPackageRef( + packageSpecifier: string, + symbolName: string, + dependencyMetadata: MetadataOutput, +): RefOrInline | null { + // Search all categories for the symbol + const categories = ['components', 'hooks', 'types', 'others'] as const; + for (const category of categories) { + const symbolsMap = dependencyMetadata.categories[category]; + if (symbolName in symbolsMap) { + return { $ref: `${packageSpecifier}#/categories/${category}/${symbolName}` }; + } + } + + return null; +} + +/** + * Resolve the directory of an npm package from node_modules. + */ +function resolvePackageDir(packageSpecifier: string, cwd: string): string | null { + // Walk up to find node_modules containing this package + let dir = path.resolve(cwd); + const root = path.parse(dir).root; + + while (dir !== root) { + const candidate = path.join(dir, 'node_modules', packageSpecifier); + if (fs.existsSync(candidate)) { + return candidate; + } + dir = path.dirname(dir); + } + + return null; +} diff --git a/tools/cli/src/commands/metadata/impl/dts-parser.spec.ts b/tools/cli/src/commands/metadata/impl/dts-parser.spec.ts new file mode 100644 index 00000000000000..5f0f04514dc299 --- /dev/null +++ b/tools/cli/src/commands/metadata/impl/dts-parser.spec.ts @@ -0,0 +1,245 @@ +import * as path from 'node:path'; + +import { parseDtsEntry, type ParseResult } from './dts-parser'; + +const FIXTURES_DIR = path.resolve(__dirname, '../__fixtures__'); +const SAMPLE_DTS = path.join(FIXTURES_DIR, 'sample-button.d.ts'); + +describe('parseDtsEntry', () => { + let result: ParseResult; + + beforeAll(() => { + result = parseDtsEntry(SAMPLE_DTS); + }); + + describe('components', () => { + it('should detect SampleButton as a component', () => { + expect(result.components).toHaveProperty('SampleButton'); + }); + + it('should extract component description', () => { + expect(result.components.SampleButton.description).toContain( + 'SampleButton gives people a way to trigger an action', + ); + }); + + it('should extract component type signature', () => { + expect(result.components.SampleButton.typeSignature).toContain('ForwardRefExoticComponent'); + }); + + it('should extract propsType reference', () => { + const propsType = result.components.SampleButton.propsType; + expect(propsType).toBeDefined(); + expect(propsType).toHaveProperty('$ref'); + expect((propsType as { $ref: string }).$ref).toBe('#/categories/types/SampleButtonProps'); + }); + }); + + describe('hooks', () => { + it('should detect useSampleButton_unstable as a hook', () => { + expect(result.hooks).toHaveProperty('useSampleButton_unstable'); + }); + + it('should detect useSampleButtonStyles_unstable as a hook', () => { + expect(result.hooks).toHaveProperty('useSampleButtonStyles_unstable'); + }); + + it('should detect useToggleState as a hook', () => { + expect(result.hooks).toHaveProperty('useToggleState'); + }); + + it('should extract hook parameters', () => { + const hook = result.hooks.useSampleButton_unstable; + expect(hook.parameters).toHaveLength(2); + expect(hook.parameters[0].name).toBe('props'); + expect(hook.parameters[1].name).toBe('ref'); + }); + + it('should extract @param descriptions', () => { + const hook = result.hooks.useSampleButton_unstable; + expect(hook.parameters[0].description).toContain('User provided props'); + }); + + it('should extract hook return type', () => { + const hook = result.hooks.useSampleButton_unstable; + expect(hook.returnType).toContain('SampleButtonState'); + }); + }); + + describe('types', () => { + it('should detect SampleButtonProps as a type (interface)', () => { + expect(result.types).toHaveProperty('SampleButtonProps'); + expect(result.types.SampleButtonProps.kind).toBe('interface'); + }); + + it('should detect SampleButtonSlots as a type (type-alias)', () => { + expect(result.types).toHaveProperty('SampleButtonSlots'); + expect(result.types.SampleButtonSlots.kind).toBe('type-alias'); + }); + + it('should detect SampleButtonSize as a type (type-alias)', () => { + expect(result.types).toHaveProperty('SampleButtonSize'); + }); + + it('should not extract string prototype members for string literal union types', () => { + const type = result.types.SampleButtonSize; + expect(Object.keys(type.members)).toHaveLength(0); + }); + + it('should strip inline JSDoc from type alias type signatures', () => { + const type = result.types.SampleStateOptions; + expect(type).toBeDefined(); + expect(type.typeSignature).not.toContain('/**'); + expect(type.typeSignature).not.toContain('*/'); + expect(type.typeSignature).toContain('defaultState'); + expect(type.typeSignature).toContain('state'); + }); + + it('should detect ButtonVariant as an enum', () => { + expect(result.types).toHaveProperty('ButtonVariant'); + expect(result.types.ButtonVariant.kind).toBe('enum'); + }); + + it('should extract interface members', () => { + const props = result.types.SampleButtonProps; + expect(props.members).toHaveProperty('appearance'); + expect(props.members).toHaveProperty('disabled'); + expect(props.members).toHaveProperty('size'); + }); + + it('should extract interface method signatures as members', () => { + const methods = result.types.SampleSelectionMethods; + expect(methods).toBeDefined(); + expect(methods.members).toHaveProperty('selectItem'); + expect(methods.members).toHaveProperty('isSelected'); + expect(methods.members.selectItem.type).toContain('void'); + expect(methods.members.isSelected.type).toContain('boolean'); + }); + + it('should extract member types', () => { + const appearance = result.types.SampleButtonProps.members.appearance; + expect(appearance.type).toContain('primary'); + expect(appearance.required).toBe(false); + }); + + it('should extract @default values', () => { + const appearance = result.types.SampleButtonProps.members.appearance; + expect(appearance.defaultValue).toBe("'secondary'"); + }); + + it('should extract member descriptions', () => { + const appearance = result.types.SampleButtonProps.members.appearance; + expect(appearance.description).toContain('visual style'); + }); + + it('should extract @deprecated tag', () => { + const variant = result.types.ButtonVariant; + expect(variant.tags).toHaveProperty('deprecated'); + }); + + it('should extract enum members', () => { + const variant = result.types.ButtonVariant; + expect(variant.members).toHaveProperty('Primary'); + expect(variant.members).toHaveProperty('Secondary'); + expect(variant.members.Primary.defaultValue).toBe('primary'); + }); + }); + + describe('others', () => { + it('should detect sampleButtonClassNames as other (variable)', () => { + expect(result.others).toHaveProperty('sampleButtonClassNames'); + expect(result.others.sampleButtonClassNames.kind).toBe('variable'); + }); + + it('should detect renderSampleButton_unstable as other (function)', () => { + expect(result.others).toHaveProperty('renderSampleButton_unstable'); + expect(result.others.renderSampleButton_unstable.kind).toBe('function'); + expect(result.others.renderSampleButton_unstable.parameters).toBeDefined(); + expect(result.others.renderSampleButton_unstable.parameters!.length).toBeGreaterThan(0); + expect(result.others.renderSampleButton_unstable.returnType).toBeDefined(); + }); + + it('should rename __0 destructured params to arg0', () => { + const fn = result.others.getPartitionedProps; + expect(fn).toBeDefined(); + expect(fn.kind).toBe('function'); + expect(fn.parameters).toBeDefined(); + expect(fn.parameters![0].name).toBe('arg0'); + expect(fn.parameters![0].name).not.toContain('__'); + }); + + it('should extract render function description', () => { + expect(result.others.renderSampleButton_unstable.description).toContain('Renders SampleButton'); + }); + + it('should extract @internal tag', () => { + expect(result.types.SampleButtonContextValue.tags).toHaveProperty('internal'); + }); + }); + + describe('importedPackages', () => { + it('should track imported package specifiers', () => { + expect(result.importedPackages.has('react')).toBe(true); + expect(result.importedPackages.has('@sample/utilities')).toBe(true); + }); + }); + + describe('importedSymbols', () => { + it('should track named imports per package', () => { + expect(result.importedSymbols.has('@sample/utilities')).toBe(true); + const symbols = result.importedSymbols.get('@sample/utilities')!; + expect(symbols.has('Slot')).toBe(true); + expect(symbols.has('SlotClassNames')).toBe(true); + }); + + it('should not track namespace imports as named symbols', () => { + // `import * as React from 'react'` should not produce named import entries + expect(result.importedSymbols.has('react')).toBe(false); + }); + }); + + describe('classification boundaries', () => { + it('should not misclassify hooks as others', () => { + for (const name of Object.keys(result.others)) { + expect(name).not.toMatch(/^use[A-Z]/); + } + }); + + it('should not misclassify types as others', () => { + for (const name of Object.keys(result.others)) { + expect(result.types).not.toHaveProperty(name); + } + }); + + it('should classify camelCase function returning ReactElement as other, not component', () => { + expect(result.others).toHaveProperty('getTriggerChild'); + expect(result.others.getTriggerChild.kind).toBe('function'); + expect(result.components).not.toHaveProperty('getTriggerChild'); + }); + + it('should classify PascalCase function returning JSX as component', () => { + expect(result.components).toHaveProperty('PascalCaseComponent'); + expect(result.others).not.toHaveProperty('PascalCaseComponent'); + }); + + it('should produce full type signature for function declarations, not typeof', () => { + const hook = result.hooks.useToggleState; + expect(hook.typeSignature).not.toContain('typeof'); + expect(hook.typeSignature).toContain('function useToggleState'); + }); + + it('should produce full type signature for other function declarations', () => { + const fn = result.others.getTriggerChild; + expect(fn.typeSignature).not.toContain('typeof'); + expect(fn.typeSignature).toContain('function getTriggerChild'); + }); + + it('should strip inline JSDoc from function declaration type signatures', () => { + const fn = result.others.isHTMLElement; + expect(fn).toBeDefined(); + expect(fn.typeSignature).not.toContain('/**'); + expect(fn.typeSignature).not.toContain('*/'); + expect(fn.typeSignature).toContain('constructorName'); + }); + }); +}); diff --git a/tools/cli/src/commands/metadata/impl/dts-parser.ts b/tools/cli/src/commands/metadata/impl/dts-parser.ts new file mode 100644 index 00000000000000..fd08939b10a880 --- /dev/null +++ b/tools/cli/src/commands/metadata/impl/dts-parser.ts @@ -0,0 +1,720 @@ +import { + Project, + Node, + SyntaxKind, + type SourceFile, + type ExportedDeclarations, + type TypeAliasDeclaration, + type InterfaceDeclaration, + type FunctionDeclaration, + type VariableDeclaration, + type EnumDeclaration, + type ClassDeclaration, + type Symbol as TsMorphSymbol, +} from 'ts-morph'; + +import type { + SymbolClassification, + ComponentDoc, + HookDoc, + TypeDoc, + OtherDoc, + ParameterDoc, + MemberDoc, + RefOrInline, +} from './types'; + +/** Type patterns that indicate a React component. */ +const COMPONENT_TYPE_PATTERNS = [ + 'ForwardRefComponent', + 'React.FC', + 'React.FunctionComponent', + 'React.ForwardRefExoticComponent', + 'FC<', + 'FunctionComponent<', + 'ForwardRefExoticComponent<', +]; + +/** JSX return type names. */ +const JSX_TYPE_NAMES = new Set([ + 'Element', + 'ReactElement', + 'ReactNode', + 'JSX.Element', + 'React.ReactElement', + 'React.ReactNode', + 'JSXElement', +]); + +/** + * Result of parsing a .d.ts entry file. + */ +export interface ParseResult { + components: Record; + hooks: Record; + types: Record; + others: Record; + /** Package specifiers imported by this .d.ts (for cross-package resolution). */ + importedPackages: Set; + /** Named imports grouped by package specifier (e.g. `'@fluentui/react-utilities' → Set('Slot', 'ComponentProps')`). */ + importedSymbols: Map>; +} + +/** + * Parse a .d.ts entry file and extract all exported API symbols. + */ +export function parseDtsEntry(entryPath: string): ParseResult { + const project = new Project({ + compilerOptions: { + declaration: true, + // Allow following imports into node_modules .d.ts + moduleResolution: 2 /* NodeJs */, + }, + skipAddingFilesFromTsConfig: true, + }); + + const sourceFile = project.addSourceFileAtPath(entryPath); + project.resolveSourceFileDependencies(); + + const result: ParseResult = { + components: {}, + hooks: {}, + types: {}, + others: {}, + importedPackages: new Set(), + importedSymbols: new Map(), + }; + + // Collect imported package specifiers and named imports + for (const imp of sourceFile.getImportDeclarations()) { + const specifier = imp.getModuleSpecifierValue(); + if (specifier.startsWith('.') || specifier.startsWith('/')) { + continue; + } + + result.importedPackages.add(specifier); + + // Track individual named imports + const namedImports = imp.getNamedImports(); + if (namedImports.length > 0) { + if (!result.importedSymbols.has(specifier)) { + result.importedSymbols.set(specifier, new Set()); + } + const symbolSet = result.importedSymbols.get(specifier)!; + for (const named of namedImports) { + // Use the alias if present, otherwise the original name + symbolSet.add(named.getAliasNode()?.getText() ?? named.getName()); + } + } + + // Track default imports + const defaultImport = imp.getDefaultImport(); + if (defaultImport) { + if (!result.importedSymbols.has(specifier)) { + result.importedSymbols.set(specifier, new Set()); + } + result.importedSymbols.get(specifier)!.add(defaultImport.getText()); + } + } + + // Process all exports + const exportedDecls = sourceFile.getExportedDeclarations(); + + for (const [name, declarations] of exportedDecls) { + // Skip the empty re-export `export { }` + if (name === '') { + continue; + } + + const decl = declarations[0]; + if (!decl) { + continue; + } + + const classification = classifyDeclaration(name, decl); + const description = extractJsDoc(decl); + const tags = extractJsDocTags(decl); + + switch (classification) { + case 'component': + result.components[name] = buildComponentDoc(name, decl, description, tags); + break; + case 'hook': + result.hooks[name] = buildHookDoc(name, decl, description, tags); + break; + case 'type': + result.types[name] = buildTypeDoc(name, decl, description, tags); + break; + case 'other': + result.others[name] = buildOtherDoc(name, decl, description, tags); + break; + } + } + + return result; +} + +// ============================================================================ +// Classification +// ============================================================================ + +function classifyDeclaration(name: string, decl: ExportedDeclarations): SymbolClassification { + // Hook: use* naming convention + if (/^use[A-Z]/.test(name)) { + return 'hook'; + } + + // Type: interface, type alias, or enum + if (Node.isInterfaceDeclaration(decl) || Node.isTypeAliasDeclaration(decl) || Node.isEnumDeclaration(decl)) { + return 'type'; + } + + // Component detection + if (Node.isVariableDeclaration(decl)) { + const typeText = getTypeText(decl); + if (isReactComponentType(typeText)) { + return 'component'; + } + } + + // PascalCase function/variable returning JSX → component + // camelCase functions returning ReactElement (e.g. getTriggerChild) are utilities, not components + if (/^[A-Z]/.test(name)) { + if (Node.isFunctionDeclaration(decl)) { + const returnType = safeGetReturnTypeText(decl); + if (returnsJsx(returnType)) { + return 'component'; + } + } + + if (Node.isVariableDeclaration(decl)) { + const typeText = getTypeText(decl); + if (returnsJsx(typeText)) { + return 'component'; + } + } + } + + return 'other'; +} + +function isReactComponentType(typeText: string): boolean { + return COMPONENT_TYPE_PATTERNS.some(p => typeText.includes(p)); +} + +function returnsJsx(typeText: string): boolean { + for (const jsxType of JSX_TYPE_NAMES) { + if (typeText.includes(jsxType)) { + return true; + } + } + return false; +} + +// ============================================================================ +// Builders +// ============================================================================ + +function buildComponentDoc( + name: string, + decl: ExportedDeclarations, + description: string, + tags: Record, +): ComponentDoc { + const typeSignature = getTypeText(decl); + const propsType = extractPropsTypeRef(typeSignature); + + return { name, description, typeSignature, tags, propsType }; +} + +function buildHookDoc( + name: string, + decl: ExportedDeclarations, + description: string, + tags: Record, +): HookDoc { + const typeSignature = getTypeText(decl); + const parameters = extractParameters(decl, tags); + const returnType = extractReturnType(decl); + + return { name, description, typeSignature, tags, parameters, returnType }; +} + +function buildTypeDoc( + name: string, + decl: ExportedDeclarations, + description: string, + tags: Record, +): TypeDoc { + let kind: TypeDoc['kind'] = 'type-alias'; + if (Node.isInterfaceDeclaration(decl)) { + kind = 'interface'; + } else if (Node.isEnumDeclaration(decl)) { + kind = 'enum'; + } + + const typeSignature = getTypeText(decl); + const members = extractMembers(decl); + + return { name, description, typeSignature, tags, kind, members }; +} + +function buildOtherDoc( + name: string, + decl: ExportedDeclarations, + description: string, + tags: Record, +): OtherDoc { + let kind: OtherDoc['kind'] = 'unknown'; + if (Node.isVariableDeclaration(decl)) { + kind = isFunctionTypedVariable(decl) ? 'function' : 'variable'; + } else if (Node.isFunctionDeclaration(decl)) { + kind = 'function'; + } else if (Node.isClassDeclaration(decl)) { + kind = 'class'; + } + + const typeSignature = getTypeText(decl); + const doc: OtherDoc = { name, description, typeSignature, tags, kind }; + + if (kind === 'function') { + doc.parameters = extractParameters(decl, tags); + doc.returnType = extractReturnType(decl); + } + + return doc; +} + +/** + * Checks whether a VariableDeclaration holds a function-typed value + * by inspecting its type for call signatures. + */ +function isFunctionTypedVariable(decl: VariableDeclaration): boolean { + try { + const type = decl.getType(); + return type.getCallSignatures().length > 0; + } catch { + return false; + } +} + +// ============================================================================ +// JSDoc extraction +// ============================================================================ + +function extractJsDoc(decl: ExportedDeclarations): string { + // For VariableDeclarations, JSDoc sits on the parent VariableStatement + const target = getJsDocTarget(decl); + + if (!('getJsDocs' in target)) { + return ''; + } + + try { + const jsDocs = (target as unknown as { getJsDocs(): Array<{ getDescription(): string }> }).getJsDocs(); + if (jsDocs.length === 0) { + return ''; + } + return jsDocs + .map(doc => doc.getDescription().trim()) + .filter(Boolean) + .join('\n'); + } catch { + return ''; + } +} + +function extractJsDocTags(decl: ExportedDeclarations): Record { + const target = getJsDocTarget(decl); + + if (!('getJsDocs' in target)) { + return {}; + } + + const tags: Record = {}; + try { + const jsDocs = ( + target as unknown as { + getJsDocs(): Array<{ getTags(): Array<{ getTagName(): string; getCommentText(): string | undefined }> }>; + } + ).getJsDocs(); + for (const doc of jsDocs) { + for (const tag of doc.getTags()) { + const tagName = tag.getTagName(); + const tagValue = tag.getCommentText() ?? ''; + // Skip @param tags — they are handled separately + if (tagName !== 'param') { + tags[tagName] = tagValue.trim(); + } + } + } + } catch { + // Swallow — tags are best-effort + } + + return tags; +} + +/** + * Extract @param descriptions from JSDoc tags. + */ +function extractParamDescriptions(decl: ExportedDeclarations): Map { + const target = getJsDocTarget(decl); + const map = new Map(); + if (!('getJsDocs' in target)) { + return map; + } + + try { + const jsDocs = ( + target as unknown as { + getJsDocs(): Array<{ + getTags(): Array<{ getTagName(): string; getCommentText(): string | undefined; getName?(): string }>; + }>; + } + ).getJsDocs(); + for (const doc of jsDocs) { + for (const tag of doc.getTags()) { + if (tag.getTagName() === 'param' && tag.getName) { + map.set(tag.getName!(), (tag.getCommentText() ?? '').trim()); + } + } + } + } catch { + // best-effort + } + + return map; +} + +/** + * Get the node that carries JSDoc comments. + * For VariableDeclarations, JSDoc is on the parent VariableStatement. + */ +function getJsDocTarget(decl: ExportedDeclarations): Node { + if (Node.isVariableDeclaration(decl)) { + const varDeclList = decl.getParent(); + if (varDeclList) { + const varStatement = varDeclList.getParent(); + if (varStatement && 'getJsDocs' in varStatement) { + return varStatement as Node; + } + } + } + return decl; +} + +// ============================================================================ +// Type extraction helpers +// ============================================================================ + +function getTypeText(decl: ExportedDeclarations): string { + try { + let text = ''; + + if (Node.isVariableDeclaration(decl)) { + // Prefer the explicit type annotation (e.g. `unique symbol`, `typeof React.useEffect`) + // over the resolved type which often produces unhelpful `typeof varName`. + text = decl.getTypeNode()?.getText() ?? decl.getType().getText(decl); + } else if (Node.isFunctionDeclaration(decl)) { + // decl.getType().getText() returns "typeof funcName" for function declarations. + // Use the declaration text stripped of 'export'/'declare' keywords for a full signature. + text = decl.getText().trim(); + text = text.replace(/^export\s+/, '').replace(/^declare\s+/, ''); + } else if (Node.isTypeAliasDeclaration(decl)) { + text = decl.getTypeNode()?.getText() ?? decl.getType().getText(decl); + } else if (Node.isInterfaceDeclaration(decl)) { + // For interfaces, show the heritage and structure + const heritageText = decl + .getExtends() + .map(e => e.getText()) + .join(' & '); + const membersPreview = decl.getMembers().length > 0 ? '{ ... }' : '{}'; + text = heritageText ? `${heritageText} & ${membersPreview}` : membersPreview; + } else if (Node.isEnumDeclaration(decl)) { + const members = decl.getMembers().map(m => m.getName()); + text = `enum { ${members.join(', ')} }`; + } else { + text = decl.getType().getText(decl); + } + + return stripJsDocComments(text); + } catch { + return ''; + } +} + +/** + * Strip block comments (including JSDoc) from type text and normalize whitespace. + * Type annotations in .d.ts files can contain inline JSDoc on object members. + */ +function stripJsDocComments(text: string): string { + text = text.replace(/\/\*[\s\S]*?\*\//g, ''); + text = text.replace(/\s+/g, ' ').trim(); + return text; +} + +function safeGetReturnTypeText(decl: FunctionDeclaration): string { + try { + return decl.getReturnType().getText(decl); + } catch { + return ''; + } +} + +function extractReturnType(decl: ExportedDeclarations): string { + try { + if (Node.isFunctionDeclaration(decl)) { + const rtNode = decl.getReturnTypeNode(); + return rtNode?.getText() ?? decl.getReturnType().getText(decl); + } + if (Node.isVariableDeclaration(decl)) { + // For arrow function variables, try to get the call signatures + const type = decl.getType(); + const callSigs = type.getCallSignatures(); + if (callSigs.length > 0) { + return callSigs[0].getReturnType().getText(decl); + } + } + } catch { + // fall through + } + return ''; +} + +function extractParameters(decl: ExportedDeclarations, jsdocTags: Record): ParameterDoc[] { + const paramDescriptions = extractParamDescriptions(decl); + const params: ParameterDoc[] = []; + + try { + if (Node.isFunctionDeclaration(decl)) { + for (const param of decl.getParameters()) { + const name = param.getName(); + params.push({ + name, + type: param.getTypeNode()?.getText() ?? param.getType().getText(decl), + required: !param.isOptional(), + description: paramDescriptions.get(name) ?? '', + }); + } + } else if (Node.isVariableDeclaration(decl)) { + // Arrow function variable — parse call signatures + const type = decl.getType(); + const callSigs = type.getCallSignatures(); + if (callSigs.length > 0) { + for (let i = 0; i < callSigs[0].getParameters().length; i++) { + const param = callSigs[0].getParameters()[i]; + const paramDecl = param.getDeclarations()[0]; + const rawName = param.getName(); + // TypeScript uses __0, __1 etc. for destructured parameters; use arg0, arg1 instead + const name = /^__\d+$/.test(rawName) ? `arg${rawName.slice(2)}` : rawName; + params.push({ + name, + type: paramDecl ? paramDecl.getType().getText(paramDecl) : param.getDeclaredType().getText(), + required: paramDecl ? !Node.isParameterDeclaration(paramDecl) || !paramDecl.isOptional() : true, + description: paramDescriptions.get(rawName) ?? paramDescriptions.get(name) ?? '', + }); + } + } + } + } catch { + // best-effort + } + + return params; +} + +// ============================================================================ +// Member extraction (for interfaces and type aliases) +// ============================================================================ + +function extractMembers(decl: ExportedDeclarations): Record { + const members: Record = {}; + + try { + if (Node.isInterfaceDeclaration(decl)) { + extractInterfaceMembers(decl, members); + } else if (Node.isTypeAliasDeclaration(decl)) { + extractTypeAliasMembers(decl, members); + } else if (Node.isEnumDeclaration(decl)) { + extractEnumMembers(decl, members); + } + } catch { + // best-effort + } + + return members; +} + +function extractInterfaceMembers(decl: InterfaceDeclaration, members: Record): void { + for (const prop of decl.getProperties()) { + const name = prop.getName(); + const jsDocDescription = extractPropertyJsDoc(prop); + const defaultValue = extractDefaultTag(prop); + + members[name] = { + name, + type: prop.getTypeNode()?.getText() ?? prop.getType().getText(prop), + required: !prop.hasQuestionToken(), + description: jsDocDescription, + ...(defaultValue !== undefined ? { defaultValue } : {}), + }; + } + + for (const method of decl.getMethods()) { + const name = method.getName(); + const jsDocDescription = extractPropertyJsDoc(method); + const params = method + .getParameters() + .map(p => p.getText()) + .join(', '); + const returnType = method.getReturnTypeNode()?.getText() ?? ''; + const typeSignature = `(${params}) => ${returnType}`; + + members[name] = { + name, + type: stripJsDocComments(typeSignature), + required: !method.hasQuestionToken(), + description: jsDocDescription, + }; + } +} + +function extractTypeAliasMembers(decl: TypeAliasDeclaration, members: Record): void { + const type = decl.getType(); + + // Skip member extraction for primitive, literal, and non-object types. + // e.g. `type SelectionMode = 'single' | 'multiselect'` would incorrectly + // yield String.prototype members (toString, charAt, …) via getProperties(). + if (type.isUnion()) { + const allPrimitive = type + .getUnionTypes() + .every( + t => + t.isStringLiteral() || + t.isNumberLiteral() || + t.isBooleanLiteral() || + t.isString() || + t.isNumber() || + t.isBoolean() || + t.isUndefined() || + t.isNull(), + ); + if (allPrimitive) { + return; + } + } + if ( + type.isStringLiteral() || + type.isNumberLiteral() || + type.isBooleanLiteral() || + type.isString() || + type.isNumber() || + type.isBoolean() || + type.isUndefined() || + type.isNull() + ) { + return; + } + + for (const prop of type.getProperties()) { + const propDecl = prop.getDeclarations()[0]; + if (!propDecl) { + continue; + } + + const name = prop.getName(); + const jsDocDescription = extractPropertyJsDoc(propDecl); + const defaultValue = extractDefaultTag(propDecl); + + members[name] = { + name, + type: propDecl.getType().getText(propDecl), + required: !( + 'hasQuestionToken' in propDecl && + typeof propDecl.hasQuestionToken === 'function' && + propDecl.hasQuestionToken() + ), + description: jsDocDescription, + ...(defaultValue !== undefined ? { defaultValue } : {}), + }; + } +} + +function extractEnumMembers(decl: EnumDeclaration, members: Record): void { + for (const member of decl.getMembers()) { + const name = member.getName(); + const value = member.getValue(); + members[name] = { + name, + type: typeof value === 'string' ? 'string' : 'number', + required: true, + description: extractPropertyJsDoc(member), + ...(value !== undefined ? { defaultValue: String(value) } : {}), + }; + } +} + +// ============================================================================ +// Property-level JSDoc helpers +// ============================================================================ + +function extractPropertyJsDoc(node: Node): string { + try { + if ('getJsDocs' in node) { + const jsDocs = (node as unknown as { getJsDocs(): Array<{ getDescription(): string }> }).getJsDocs(); + return jsDocs + .map(doc => doc.getDescription().trim()) + .filter(Boolean) + .join('\n'); + } + } catch { + // fall through + } + return ''; +} + +function extractDefaultTag(node: Node): string | undefined { + try { + if ('getJsDocs' in node) { + const jsDocs = ( + node as unknown as { + getJsDocs(): Array<{ getTags(): Array<{ getTagName(): string; getCommentText(): string | undefined }> }>; + } + ).getJsDocs(); + for (const doc of jsDocs) { + for (const tag of doc.getTags()) { + if (tag.getTagName() === 'default') { + return (tag.getCommentText() ?? '').trim(); + } + } + } + } + } catch { + // fall through + } + return undefined; +} + +// ============================================================================ +// Props type reference extraction +// ============================================================================ + +/** + * Extract a `$ref` to the props type from a component's type signature. + * e.g. `ForwardRefExoticComponent>` → `{ "$ref": "#/categories/types/ButtonProps" }` + */ +function extractPropsTypeRef(typeSignature: string): RefOrInline | undefined { + // Match patterns like ForwardRefComponent, React.FC, etc. + // The props type is the first type argument before any `&` or `>` + const match = typeSignature.match( + /(?:ForwardRefComponent|ForwardRefExoticComponent|React\.FC|React\.FunctionComponent|FC|FunctionComponent)<\s*([A-Z][A-Za-z0-9_]*)/, + ); + + if (match) { + const propsTypeName = match[1]; + return { $ref: `#/categories/types/${propsTypeName}` }; + } + + return undefined; +} diff --git a/tools/cli/src/commands/metadata/impl/entry-resolver.spec.ts b/tools/cli/src/commands/metadata/impl/entry-resolver.spec.ts new file mode 100644 index 00000000000000..85a39081fa75c5 --- /dev/null +++ b/tools/cli/src/commands/metadata/impl/entry-resolver.spec.ts @@ -0,0 +1,52 @@ +import * as path from 'node:path'; +import * as fs from 'node:fs'; + +import { resolveEntry, readPackageInfo } from './entry-resolver'; + +const FIXTURES_DIR = path.resolve(__dirname, '../__fixtures__'); + +describe('resolveEntry', () => { + it('should resolve from package.json "types" field', () => { + const result = resolveEntry(undefined, FIXTURES_DIR); + + expect(result).toBe(path.join(FIXTURES_DIR, 'sample-button.d.ts')); + expect(fs.existsSync(result)).toBe(true); + }); + + it('should accept an explicit entry override', () => { + const entryPath = path.join(FIXTURES_DIR, 'sample-button.d.ts'); + const result = resolveEntry(entryPath); + + expect(result).toBe(entryPath); + }); + + it('should resolve from package.json when entry override is a directory', () => { + const result = resolveEntry(FIXTURES_DIR); + + expect(result).toBe(path.join(FIXTURES_DIR, 'sample-button.d.ts')); + }); + + it('should throw when explicit entry does not exist', () => { + expect(() => resolveEntry('/nonexistent/file.d.ts')).toThrow('Entry file not found'); + }); + + it('should throw when no package.json is found', () => { + expect(() => resolveEntry(undefined, '/')).toThrow('Could not find package.json'); + }); +}); + +describe('readPackageInfo', () => { + it('should read name and version from package.json', () => { + const info = readPackageInfo(FIXTURES_DIR); + + expect(info.name).toBe('@fluentui/sample-button'); + expect(info.version).toBe('1.0.0'); + }); + + it('should return defaults when no package.json found', () => { + const info = readPackageInfo('/'); + + expect(info.name).toBe('unknown'); + expect(info.version).toBe('0.0.0'); + }); +}); diff --git a/tools/cli/src/commands/metadata/impl/entry-resolver.ts b/tools/cli/src/commands/metadata/impl/entry-resolver.ts new file mode 100644 index 00000000000000..31f3b65355cdee --- /dev/null +++ b/tools/cli/src/commands/metadata/impl/entry-resolver.ts @@ -0,0 +1,93 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +/** + * Resolve the .d.ts entry file for metadata extraction. + * + * Resolution order: + * 1. If `entryOverride` is provided, use it directly. + * 2. Otherwise, find the closest package.json and read the "types" or "typings" field. + * + * @returns Absolute path to the .d.ts entry file. + * @throws If no entry can be resolved or the file does not exist. + */ +export function resolveEntry(entryOverride?: string, cwd: string = process.cwd()): string { + if (entryOverride) { + const resolved = path.resolve(cwd, entryOverride); + if (!fs.existsSync(resolved)) { + throw new Error(`Entry file not found: ${resolved}`); + } + + // If the override points to a directory, resolve its package.json types field + if (fs.statSync(resolved).isDirectory()) { + return resolveFromPackageJson(resolved); + } + + return resolved; + } + + return resolveFromPackageJson(cwd); +} + +/** + * Read package name and version from the closest package.json. + */ +export function readPackageInfo(cwd: string = process.cwd()): { name: string; version: string } { + const packageJsonPath = findClosestPackageJson(cwd); + if (!packageJsonPath) { + return { name: 'unknown', version: '0.0.0' }; + } + + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + return { + name: packageJson.name ?? 'unknown', + version: packageJson.version ?? '0.0.0', + }; +} + +/** + * Resolve the .d.ts entry from the closest package.json in `startDir`. + */ +function resolveFromPackageJson(startDir: string): string { + const packageJsonPath = findClosestPackageJson(startDir); + if (!packageJsonPath) { + throw new Error(`Could not find package.json from ${startDir}`); + } + + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + const typesField: string | undefined = packageJson.types ?? packageJson.typings; + + if (!typesField) { + throw new Error( + `No "types" or "typings" field found in ${packageJsonPath}. ` + 'Use --entry to specify the .d.ts file manually.', + ); + } + + const resolved = path.resolve(path.dirname(packageJsonPath), typesField); + if (!fs.existsSync(resolved)) { + throw new Error( + `Types entry "${typesField}" resolved to ${resolved} but the file does not exist. ` + + 'Has the package been built?', + ); + } + + return resolved; +} + +/** + * Walk up from `startDir` looking for a package.json. + */ +function findClosestPackageJson(startDir: string): string | null { + let dir = path.resolve(startDir); + const root = path.parse(dir).root; + + while (dir !== root) { + const candidate = path.join(dir, 'package.json'); + if (fs.existsSync(candidate)) { + return candidate; + } + dir = path.dirname(dir); + } + + return null; +} diff --git a/tools/cli/src/commands/metadata/impl/html-formatter.ts b/tools/cli/src/commands/metadata/impl/html-formatter.ts new file mode 100644 index 00000000000000..db77cf021900c1 --- /dev/null +++ b/tools/cli/src/commands/metadata/impl/html-formatter.ts @@ -0,0 +1,370 @@ +import type { + MetadataOutput, + ComponentDoc, + HookDoc, + TypeDoc, + OtherDoc, + MemberDoc, + ParameterDoc, + BaseSymbolDoc, + ExternalPackageRef, +} from './types'; +import { groupByAnnotation, type AnnotationGroup } from './annotation-groups'; + +/** + * Format MetadataOutput as a self-contained HTML document. + */ +export function formatMetadataAsHtml(data: MetadataOutput): string { + const { package: pkg, legend, categories, externalReferences } = data; + + return ` + + + + +API Metadata: ${esc(pkg.name)} +${renderStyles()} + + +
+

API Metadata: ${esc(pkg.name)} v${esc(pkg.version)}

+${renderLegend(legend)} +${renderSummary(categories)} +${renderComponents(categories.components)} +${renderHooks(categories.hooks)} +${renderTypes(categories.types)} +${renderOthers(categories.others)} +${renderExternalReferences(externalReferences)} +
+ +`; +} + +// --------------------------------------------------------------------------- +// Sections +// --------------------------------------------------------------------------- + +function renderLegend(legend: MetadataOutput['legend']): string { + const rows = Object.values(legend) + .map(e => `${esc(e.name)}${esc(e.description)}`) + .join('\n'); + + return `
+

Legend

+ +${rows}
CategoryDescription
+
`; +} + +function renderSummary(categories: MetadataOutput['categories']): string { + const counts = [ + ['Components', 'cat-components', Object.keys(categories.components).length], + ['Hooks', 'cat-hooks', Object.keys(categories.hooks).length], + ['Types', 'cat-types', Object.keys(categories.types).length], + ['Others', 'cat-others', Object.keys(categories.others).length], + ] as const; + + const rows = counts + .map(([name, anchor, count]) => `${name}${count}`) + .join('\n'); + + return `
+

Summary

+ +${rows}
CategoryCount
+
`; +} + +function renderComponents(components: Record): string { + const all = Object.values(components); + if (all.length === 0) { + return ''; + } + + const groups = groupByAnnotation(all); + + return `
+

Components

(${all.length})
+${renderAnnotationGroups(groups, renderComponentItem)} +
`; +} + +function renderComponentItem(comp: ComponentDoc): string { + const propsRef = comp.propsType ? `

Props: ${renderRef(comp.propsType)}

` : ''; + + return `
+

${esc(comp.name)}

+${comp.description ? `

${esc(comp.description)}

` : ''} +

Type: ${esc(comp.typeSignature)}

+${propsRef} +${renderTagsBadges(comp.tags)} +
`; +} + +function renderHooks(hooks: Record): string { + const all = Object.values(hooks); + if (all.length === 0) { + return ''; + } + + const groups = groupByAnnotation(all); + + return `
+

Hooks

(${all.length})
+${renderAnnotationGroups(groups, renderHookItem)} +
`; +} + +function renderHookItem(hook: HookDoc): string { + return `
+

${esc(hook.name)}

+${hook.description ? `

${esc(hook.description)}

` : ''} +

Signature: ${esc(hook.typeSignature)}

+${hook.parameters.length > 0 ? renderParametersTable(hook.parameters, 'Arguments') : ''} +

Returns: ${esc(hook.returnType)}

+${renderTagsBadges(hook.tags)} +
`; +} + +function renderTypes(types: Record): string { + const all = Object.values(types); + if (all.length === 0) { + return ''; + } + + const groups = groupByAnnotation(all); + + return `
+

Types

(${all.length})
+${renderAnnotationGroups(groups, renderTypeItem)} +
`; +} + +function renderTypeItem(type: TypeDoc): string { + const memberEntries = Object.values(type.members); + return `
+

${esc(type.name)} ${esc(type.kind)}

+${type.description ? `

${esc(type.description)}

` : ''} +

Type: ${esc(type.typeSignature)}

+${memberEntries.length > 0 ? renderMembersTable(memberEntries) : ''} +${renderTagsBadges(type.tags)} +
`; +} + +function renderOthers(others: Record): string { + const all = Object.values(others); + if (all.length === 0) { + return ''; + } + + const groups = groupByAnnotation(all); + + return `
+

Others

(${all.length})
+${renderAnnotationGroups(groups, renderOtherItem)} +
`; +} + +function renderOtherItem(other: OtherDoc): string { + return `
+

${esc(other.name)} ${esc(other.kind)}

+${other.description ? `

${esc(other.description)}

` : ''} +

Type: ${esc(other.typeSignature)}

+${other.parameters && other.parameters.length > 0 ? renderParametersTable(other.parameters, 'Arguments') : ''} +${other.returnType ? `

Returns: ${esc(other.returnType)}

` : ''} +${renderTagsBadges(other.tags)} +
`; +} + +function renderExternalReferences(externalReferences?: Record): string { + if (!externalReferences || Object.keys(externalReferences).length === 0) { + return ''; + } + + const totalSymbols = Object.values(externalReferences).reduce((sum, pkg) => sum + Object.keys(pkg.symbols).length, 0); + + const packages = Object.entries(externalReferences) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([pkgSpec, pkgRef]) => { + const symbolRows = Object.entries(pkgRef.symbols) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, ref]) => { + const refDisplay = + '$ref' in ref + ? `${esc(ref.$ref)}` + : `${esc(ref.inline)}`; + return `${esc(name)}${refDisplay}`; + }) + .join('\n'); + + return `
+

${esc(pkgSpec)} (${Object.keys(pkgRef.symbols).length} symbols)

+ + + +${symbolRows} +
SymbolReference
+
`; + }) + .join('\n'); + + return `
+

External References

(${totalSymbols} symbols from ${ + Object.keys(externalReferences).length + } packages)
+${packages} +
`; +} + +// --------------------------------------------------------------------------- +// Annotation sub-group renderer +// --------------------------------------------------------------------------- + +function renderAnnotationGroups( + groups: AnnotationGroup[], + renderItem: (item: T) => string, +): string { + // When there's only one group (all stable), skip the sub-group wrapper + if (groups.length === 1 && groups[0].key === 'stable') { + return groups[0].items.map(renderItem).join('\n'); + } + + return groups + .map(g => { + const items = g.items.map(renderItem).join('\n'); + return `
+

${esc(g.label)} (${g.items.length})

+${items} +
`; + }) + .join('\n'); +} + +// --------------------------------------------------------------------------- +// Shared renderers +// --------------------------------------------------------------------------- + +function renderParametersTable(params: ParameterDoc[], title: string): string { + const rows = params + .map( + p => + `${esc(p.name)}${esc(p.type)}${ + p.required ? 'Yes' : 'No' + }${esc(p.description)}`, + ) + .join('\n'); + + return `

${esc(title)}:

+ + +${rows}
NameTypeRequiredDescription
`; +} + +function renderMembersTable(members: MemberDoc[]): string { + const rows = members + .map( + m => + `${esc(m.name)}${esc(m.type)}${ + m.required ? 'Yes' : 'No' + }${m.defaultValue ? `${esc(m.defaultValue)}` : '—'}${esc( + m.description, + )}`, + ) + .join('\n'); + + return `
Members (${members.length}) + + +${rows}
NameTypeRequiredDefaultDescription
`; +} + +function renderTagsBadges(tags: Record): string { + const entries = Object.entries(tags); + if (entries.length === 0) { + return ''; + } + const badges = entries.map(([key, val]) => `@${esc(key)}${val ? ` ${esc(val)}` : ''}`); + return `
${badges.join(' ')}
`; +} + +/** + * Render a $ref or inline type as HTML. + * Local refs (e.g. `#/categories/types/ButtonProps`) become clickable anchor links. + * Cross-package refs (e.g. `@fluentui/react-utilities#/...`) are shown as non-linked code. + */ +function renderRef(ref: { $ref: string } | { inline: string }): string { + if ('inline' in ref) { + return `${esc(ref.inline)}`; + } + + const refValue = ref.$ref; + // Local ref: #/categories// + if (refValue.startsWith('#/')) { + const symbolName = refValue.split('/').pop()!; + return `${esc(symbolName)}`; + } + + // Cross-package ref: @scope/pkg#/categories// + const hashIdx = refValue.indexOf('#/'); + if (hashIdx > 0) { + const pkgName = refValue.substring(0, hashIdx); + const symbolName = refValue.split('/').pop()!; + return `${esc(pkgName)} → ${esc(symbolName)}`; + } + + return `${esc(refValue)}`; +} + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +function esc(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function toAnchor(name: string): string { + return 'sym-' + name.replace(/[^a-zA-Z0-9_-]/g, '-'); +} + +function renderStyles(): string { + return ``; +} diff --git a/tools/cli/src/commands/metadata/impl/markdown-formatter.spec.ts b/tools/cli/src/commands/metadata/impl/markdown-formatter.spec.ts new file mode 100644 index 00000000000000..219575af1c6d6e --- /dev/null +++ b/tools/cli/src/commands/metadata/impl/markdown-formatter.spec.ts @@ -0,0 +1,64 @@ +import { formatMetadataAsMarkdown } from './markdown-formatter'; +import type { MetadataOutput } from './types'; + +describe('formatMetadataAsMarkdown', () => { + it('should escape dynamic Markdown table cell content', () => { + const data: MetadataOutput = { + package: { name: '@fluentui/example', version: '1.0.0' }, + legend: { + types: { name: 'Type | API', description: 'A \ncategory' }, + }, + categories: { + components: {}, + hooks: { + useExample: { + name: 'useExample', + description: '', + typeSignature: '() => void', + tags: {}, + parameters: [ + { name: 'value', type: 'string | undefined', required: false, description: 'First | second\nline' }, + ], + returnType: 'void', + }, + }, + types: { + ExampleProps: { + name: 'ExampleProps', + description: '', + typeSignature: '{}', + tags: {}, + kind: 'interface', + members: { + value: { + name: 'value', + type: 'string | undefined', + required: false, + defaultValue: ' | none', + description: 'A | fallback', + }, + }, + }, + }, + others: {}, + }, + externalReferences: { + '@fluentui/example-dependency': { + metadataRef: '@fluentui/example-dependency/metadata.json', + symbols: { 'External|Type': { inline: 'string | number' } }, + }, + }, + }; + + const output = formatMetadataAsMarkdown(data); + + expect(output).toContain('Type \\| API'); + expect(output).toContain('A <type> category'); + expect(output).toContain('`string \\| undefined`'); + expect(output).toContain('First \\| second line'); + expect(output).toContain('`<default> \\| none`'); + expect(output).toContain('A <script>value</script> \\| fallback'); + expect(output).toContain('`External\\|Type`'); + expect(output).toContain('`string \\| number`'); + }); +}); diff --git a/tools/cli/src/commands/metadata/impl/markdown-formatter.ts b/tools/cli/src/commands/metadata/impl/markdown-formatter.ts new file mode 100644 index 00000000000000..f7c0ca35f64c1f --- /dev/null +++ b/tools/cli/src/commands/metadata/impl/markdown-formatter.ts @@ -0,0 +1,314 @@ +import type { + MetadataOutput, + ComponentDoc, + HookDoc, + TypeDoc, + OtherDoc, + MemberDoc, + ParameterDoc, + RefOrInline, + BaseSymbolDoc, + ExternalPackageRef, +} from './types'; +import { groupByAnnotation, type AnnotationGroup } from './annotation-groups'; + +function escapeMarkdownTableCell(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\r?\n|\r/g, ' ') + .replace(/\|/g, '\\|'); +} + +/** + * Format MetadataOutput as a structured Markdown document. + */ +export function formatMetadataAsMarkdown(data: MetadataOutput): string { + const { package: pkg, legend, categories, externalReferences } = data; + const lines: string[] = []; + + lines.push(`# API Metadata: \`${pkg.name}\` v${pkg.version}`); + lines.push(''); + + // Legend + lines.push('## Legend'); + lines.push(''); + lines.push('| Category | Description |'); + lines.push('| -------- | ----------- |'); + for (const entry of Object.values(legend)) { + lines.push(`| **${escapeMarkdownTableCell(entry.name)}** | ${escapeMarkdownTableCell(entry.description)} |`); + } + lines.push(''); + + // Summary + lines.push('## Summary'); + lines.push(''); + lines.push(`| Category | Count |`); + lines.push(`| -------- | ----- |`); + lines.push(`| Components | ${Object.keys(categories.components).length} |`); + lines.push(`| Hooks | ${Object.keys(categories.hooks).length} |`); + lines.push(`| Types | ${Object.keys(categories.types).length} |`); + lines.push(`| Others | ${Object.keys(categories.others).length} |`); + lines.push(''); + + // Components + if (Object.keys(categories.components).length > 0) { + const groups = groupByAnnotation(Object.values(categories.components)); + lines.push('
'); + lines.push(`

Components (${Object.keys(categories.components).length})

`); + lines.push(''); + lines.push(...formatAnnotationGroups(groups, formatComponent)); + lines.push('
'); + lines.push(''); + } + + // Hooks + if (Object.keys(categories.hooks).length > 0) { + const groups = groupByAnnotation(Object.values(categories.hooks)); + lines.push('
'); + lines.push(`

Hooks (${Object.keys(categories.hooks).length})

`); + lines.push(''); + lines.push(...formatAnnotationGroups(groups, formatHook)); + lines.push('
'); + lines.push(''); + } + + // Types + if (Object.keys(categories.types).length > 0) { + const groups = groupByAnnotation(Object.values(categories.types)); + lines.push('
'); + lines.push(`

Types (${Object.keys(categories.types).length})

`); + lines.push(''); + lines.push(...formatAnnotationGroups(groups, formatType)); + lines.push('
'); + lines.push(''); + } + + // Others + if (Object.keys(categories.others).length > 0) { + const groups = groupByAnnotation(Object.values(categories.others)); + lines.push('
'); + lines.push(`

Others (${Object.keys(categories.others).length})

`); + lines.push(''); + lines.push(...formatAnnotationGroups(groups, formatOther)); + lines.push('
'); + lines.push(''); + } + + // External References + if (externalReferences && Object.keys(externalReferences).length > 0) { + const totalSymbols = Object.values(externalReferences).reduce( + (sum, extPkg) => sum + Object.keys(extPkg.symbols).length, + 0, + ); + + lines.push('
'); + lines.push( + `

External References (${totalSymbols} symbols from ${ + Object.keys(externalReferences).length + } packages)

`, + ); + lines.push(''); + + for (const [pkgSpec, pkgRef] of Object.entries(externalReferences).sort(([a], [b]) => a.localeCompare(b))) { + lines.push(`### \`${pkgSpec}\``); + lines.push(''); + lines.push(`*metadata:* \`${pkgRef.metadataRef}\``); + lines.push(''); + lines.push('| Symbol | Reference |'); + lines.push('| ------ | --------- |'); + for (const [name, ref] of Object.entries(pkgRef.symbols).sort(([a], [b]) => a.localeCompare(b))) { + const refValue = '$ref' in ref ? ref.$ref : ref.inline; + lines.push(`| \`${escapeMarkdownTableCell(name)}\` | \`${escapeMarkdownTableCell(refValue)}\` |`); + } + lines.push(''); + } + + lines.push('
'); + lines.push(''); + } + + return lines.join('\n'); +} + +// --------------------------------------------------------------------------- +// Section formatters +// --------------------------------------------------------------------------- + +/** + * Render annotation sub-groups within a category. + * When there's only one group (all stable), no sub-heading is emitted. + */ +function formatAnnotationGroups( + groups: AnnotationGroup[], + renderItem: (item: T) => string[], +): string[] { + if (groups.length === 1 && groups[0].key === 'stable') { + return groups[0].items.flatMap(renderItem); + } + + const lines: string[] = []; + for (const g of groups) { + lines.push(`#### ${g.label} (${g.items.length})`); + lines.push(''); + for (const item of g.items) { + lines.push(...renderItem(item)); + } + } + return lines; +} + +function formatComponent(comp: ComponentDoc): string[] { + const lines: string[] = []; + lines.push(`### \`${comp.name}\``); + lines.push(''); + if (comp.description) { + lines.push(comp.description); + lines.push(''); + } + lines.push(`**Type:** \`${comp.typeSignature}\``); + if (comp.propsType) { + lines.push(`**Props:** ${formatRef(comp.propsType)}`); + } + lines.push(...formatTags(comp.tags)); + lines.push(''); + return lines; +} + +function formatHook(hook: HookDoc): string[] { + const lines: string[] = []; + lines.push(`### \`${hook.name}\``); + lines.push(''); + if (hook.description) { + lines.push(hook.description); + lines.push(''); + } + lines.push(`**Signature:** \`${hook.typeSignature}\``); + if (hook.parameters.length > 0) { + lines.push(''); + lines.push(...formatParametersTable(hook.parameters)); + } + lines.push(`**Returns:** \`${hook.returnType}\``); + lines.push(...formatTags(hook.tags)); + lines.push(''); + return lines; +} + +function formatType(type: TypeDoc): string[] { + const lines: string[] = []; + lines.push(`### \`${type.name}\` *(${type.kind})*`); + lines.push(''); + if (type.description) { + lines.push(type.description); + lines.push(''); + } + lines.push(`**Type:** \`${type.typeSignature}\``); + lines.push(...formatTags(type.tags)); + + const memberList = Object.values(type.members); + if (memberList.length > 0) { + lines.push(''); + lines.push(...formatMembersTable(memberList)); + } + lines.push(''); + return lines; +} + +function formatOther(other: OtherDoc): string[] { + const lines: string[] = []; + lines.push(`### \`${other.name}\` *(${other.kind})*`); + lines.push(''); + if (other.description) { + lines.push(other.description); + lines.push(''); + } + lines.push(`**Type:** \`${other.typeSignature}\``); + if (other.parameters && other.parameters.length > 0) { + lines.push(''); + lines.push(...formatParametersTable(other.parameters)); + } + if (other.returnType) { + lines.push(`**Returns:** \`${other.returnType}\``); + } + lines.push(...formatTags(other.tags)); + lines.push(''); + return lines; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function formatParametersTable(params: ParameterDoc[], title: string = 'Arguments'): string[] { + const lines: string[] = []; + lines.push(`**${title}:**`); + lines.push(''); + lines.push('| Name | Type | Required | Description |'); + lines.push('| ---- | ---- | -------- | ----------- |'); + for (const p of params) { + lines.push( + `| \`${escapeMarkdownTableCell(p.name)}\` | \`${escapeMarkdownTableCell(p.type)}\` | ${ + p.required ? 'Yes' : 'No' + } | ${escapeMarkdownTableCell(p.description)} |`, + ); + } + lines.push(''); + return lines; +} + +function formatMembersTable(members: MemberDoc[]): string[] { + const lines: string[] = []; + lines.push('**Members:**'); + lines.push(''); + lines.push('| Name | Type | Required | Default | Description |'); + lines.push('| ---- | ---- | -------- | ------- | ----------- |'); + for (const m of members) { + lines.push( + `| \`${escapeMarkdownTableCell(m.name)}\` | \`${escapeMarkdownTableCell(m.type)}\` | ${ + m.required ? 'Yes' : 'No' + } | ${m.defaultValue ? `\`${escapeMarkdownTableCell(m.defaultValue)}\`` : '—'} | ${escapeMarkdownTableCell( + m.description, + )} |`, + ); + } + lines.push(''); + return lines; +} + +function formatTags(tags: Record): string[] { + const entries = Object.entries(tags); + if (entries.length === 0) { + return []; + } + return ['', ...entries.map(([key, val]) => `> @${key}${val ? ` ${val}` : ''}`)]; +} + +/** + * Format a $ref or inline type for Markdown. + * Local refs become anchor links; cross-package refs are shown as code. + */ +function formatRef(ref: RefOrInline): string { + if ('inline' in ref) { + return `\`${ref.inline}\``; + } + + const refValue = ref.$ref; + // Local ref: #/categories// → link to heading anchor + if (refValue.startsWith('#/')) { + const symbolName = refValue.split('/').pop()!; + // GitHub-style heading anchor: lowercase, spaces→hyphens + const anchor = symbolName.toLowerCase().replace(/[^a-z0-9_-]/g, '-'); + return `[\`${symbolName}\`](#${anchor})`; + } + + // Cross-package ref + const hashIdx = refValue.indexOf('#/'); + if (hashIdx > 0) { + const pkgName = refValue.substring(0, hashIdx); + const symbolName = refValue.split('/').pop()!; + return `\`${pkgName}\` → \`${symbolName}\``; + } + + return `\`${refValue}\``; +} diff --git a/tools/cli/src/commands/metadata/impl/types.ts b/tools/cli/src/commands/metadata/impl/types.ts new file mode 100644 index 00000000000000..eb8fca9a98cbc1 --- /dev/null +++ b/tools/cli/src/commands/metadata/impl/types.ts @@ -0,0 +1,143 @@ +// ============================================================================ +// Metadata command types +// ============================================================================ + +/** + * Parsed CLI arguments for the `metadata` command. + */ +export interface MetadataArgs { + /** Path to the .d.ts entry file. When omitted, resolved from package.json "types" field. */ + entry?: string; + /** Output format. Defaults to 'json'. */ + reporter?: 'json' | 'markdown' | 'html'; + /** Output file path. Defaults to stdout. */ + output?: string; +} + +// ============================================================================ +// Metadata output schema +// ============================================================================ + +/** + * Root metadata output structure written as JSON. + */ +export interface MetadataOutput { + /** Package identity. */ + package: PackageInfo; + /** Describes each category used in the output. */ + legend: Record; + /** Categorised API symbols. */ + categories: { + components: Record; + hooks: Record; + types: Record; + others: Record; + }; + /** External symbols referenced in this package's public API, grouped by source package. */ + externalReferences?: Record; +} + +export interface PackageInfo { + name: string; + version: string; +} + +/** References to external symbols from a single dependency package. */ +export interface ExternalPackageRef { + /** Logical path to the dependency's metadata file (e.g. `@fluentui/react-utilities/metadata.json`). */ + metadataRef: string; + /** Map of symbol name → $ref pointer or inline type fallback. */ + symbols: Record; +} + +export interface CategoryLegendEntry { + name: string; + description: string; +} + +// ============================================================================ +// Symbol documentation types +// ============================================================================ + +/** Base fields shared by every documented symbol. */ +export interface BaseSymbolDoc { + /** Export name as it appears in the .d.ts. */ + name: string; + /** JSDoc description (first line / summary). */ + description: string; + /** Full type signature text. */ + typeSignature: string; + /** JSDoc tags extracted from the declaration (e.g. internal, deprecated). */ + tags: Record; +} + +/** A React component export. */ +export interface ComponentDoc extends BaseSymbolDoc { + /** Reference to the props type, e.g. `{ "$ref": "#/categories/types/ButtonProps" }`. */ + propsType?: RefOrInline; +} + +/** A React hook export. */ +export interface HookDoc extends BaseSymbolDoc { + parameters: ParameterDoc[]; + returnType: string; +} + +/** A type alias, interface, or enum export. */ +export interface TypeDoc extends BaseSymbolDoc { + /** 'interface' | 'type-alias' | 'enum'. */ + kind: TypeKind; + /** Resolved members for interfaces / type aliases with object shapes. */ + members: Record; +} + +/** Any other export (constants, render functions, utility functions). */ +export interface OtherDoc extends BaseSymbolDoc { + /** 'variable' | 'function' | 'class' | 'unknown'. */ + kind: OtherKind; + /** Function parameters (when kind is 'function'). */ + parameters?: ParameterDoc[]; + /** Function return type (when kind is 'function'). */ + returnType?: string; +} + +// ============================================================================ +// Supporting types +// ============================================================================ + +export type TypeKind = 'interface' | 'type-alias' | 'enum'; +export type OtherKind = 'variable' | 'function' | 'class' | 'unknown'; + +/** A function or hook parameter. */ +export interface ParameterDoc { + name: string; + type: string; + required: boolean; + description: string; +} + +/** A member of an interface or type alias. */ +export interface MemberDoc { + name: string; + type: string; + required: boolean; + description: string; + /** Extracted from @default JSDoc tag. */ + defaultValue?: string; +} + +/** + * Either a JSON `$ref` pointer or an inline type string. + * When the referenced package has metadata.json, `$ref` is used. + * Otherwise `inline` contains the raw type signature. + */ +export type RefOrInline = { $ref: string } | { inline: string }; + +// ============================================================================ +// Classification +// ============================================================================ + +/** + * Classification of an exported symbol. + */ +export type SymbolClassification = 'component' | 'hook' | 'type' | 'other'; diff --git a/tools/cli/src/commands/metadata/index.ts b/tools/cli/src/commands/metadata/index.ts new file mode 100644 index 00000000000000..d8bab26d3db03e --- /dev/null +++ b/tools/cli/src/commands/metadata/index.ts @@ -0,0 +1,35 @@ +import type { CommandModule } from 'yargs'; + +import type { MetadataArgs } from './impl/types'; + +const command: CommandModule<{}, MetadataArgs> = { + command: 'metadata', + describe: 'Extract API metadata from package .d.ts build output', + builder: yargs => + yargs + .option('entry', { + alias: 'e', + type: 'string', + describe: 'Path to index.d.ts entry file (default: resolved from package.json "types" field)', + }) + .option('reporter', { + alias: 'r', + type: 'string', + choices: ['json', 'markdown', 'html'] as const, + default: 'json' as const, + describe: 'Output format', + }) + .option('output', { + alias: 'o', + type: 'string', + describe: 'Output file path (default: stdout)', + }) + .version(false) + .help(), + handler: async argv => { + const { handler } = await import('./handler'); + return handler(argv); + }, +}; + +export default command; diff --git a/tools/cli/src/commands/report/README.md b/tools/cli/src/commands/report/README.md new file mode 100644 index 00000000000000..d2d025f14063a1 --- /dev/null +++ b/tools/cli/src/commands/report/README.md @@ -0,0 +1,105 @@ +# `report` command + +The `report` command generates reports about Fluent UI package usage in a codebase. It has two subcommands targeting different audiences and use cases. + +## Subcommands + +### `report info` + +Quick package & environment summary intended for **end-users** reporting issues. + +```bash +fluentui report info +``` + +Outputs a copy-paste-friendly block with: + +- System info (Node version, OS, package manager) +- Installed Fluent UI and related packages with versions +- Duplicate package warnings (multiple resolved versions) + +No flags — runs against the current project and prints to stdout. + +#### Tracked packages + +| Scope | Packages | +| -------------- | ------------------------------------------------------- | +| Fluent scoped | `@fluentui/*`, `@fluentui-contrib/*` | +| Fluent related | `tabster`, `keyborg`, `@griffel/*` | +| 3rd party | `react`, `@types/react`, `typescript`, `@floating-ui/*` | + +### `report usage` + +Deep codebase analysis of Fluent UI API usage intended for the **core team** to understand how consumers use the library. + +```bash +fluentui report usage [--path ] [--reporter json|markdown|html] [--include ...] [--exclude ...] +``` + +| Flag | Alias | Default | Description | +| ------------ | ----- | --------- | -------------------------------------------- | +| `--path` | `-p` | git root | Root directory for file traversal | +| `--reporter` | `-r` | `json` | Output format: `json`, `markdown`, or `html` | +| `--include` | — | all files | Glob patterns to include | +| `--exclude` | — | none | Glob patterns to exclude | + +Traverses `.ts` and `.tsx` files (skipping gitignored files), resolves imports from tracked packages, and classifies every imported symbol into one of five categories. + +#### Categories + +| Category | What it captures | Tracked details | +| -------------- | ---------------------------------------------------- | ------------------------------------------------ | +| **Components** | React components (JSX elements) | Per-component prop usage with values | +| **Hooks** | React hooks (`use*` naming) | Call-site argument usage with values | +| **Types** | TypeScript interfaces, type aliases, enums | `typeof` reference count, generic type arguments | +| **Others** | Value exports (constants, utility functions, themes) | Call-site argument usage when invoked | +| **Unknowns** | Symbols whose `.d.ts` could not be resolved | Naming-convention-based description | + +#### Output formats + +- **JSON** — machine-readable metadata with a `legend`, `fileMap`, and per-package `packages` map. Default. +- **Markdown** — summary tables and per-package breakdowns; concise, no prop details. +- **HTML** — self-contained report with collapsible prop/argument details and dark mode support. + +## Architecture + +``` +report/ +├── index.ts # Parent command — registers subcommands +├── commands/ +│ ├── info.ts # `report info` subcommand +│ └── usage.ts # `report usage` subcommand +├── impl/ +│ ├── types.ts # All type definitions +│ ├── ast-parser.ts # ts-morph AST parser (symbol classification, JSX/call/type-ref extraction) +│ ├── file-discovery.ts # Source file traversal (respects .gitignore) +│ ├── package-resolver.ts # Package version resolution and reportable-package filtering +│ ├── usage-report.ts # Core analysis engine — collects metadata from AST parser +│ ├── info-report.ts # Package/system info collection and formatting +│ ├── markdown-reporter.ts # Markdown output formatter +│ ├── html-reporter.ts # HTML output formatter +│ └── index.ts # Barrel exports +├── __fixtures__/ # Test fixtures (sample-app with mock node_modules) +├── SPEC.md # Original specification +└── README.md # This file +``` + +### How symbol classification works + +1. **Import scanning** — all `import` declarations from tracked packages are collected +2. **Type resolution** — each symbol is resolved through its `.d.ts` declaration: + - Functions returning JSX → `component` + - `use*` naming or hook signatures → `hook` + - Interfaces, type aliases, enums → `type` + - Everything else with a resolved `.d.ts` → `other` + - Unresolvable `.d.ts` → `unknown` +3. **Usage enrichment** — JSX props, call arguments, `typeof` references, and generic type arguments are captured +4. **Deduplication** — symbols appearing in multiple categories are reconciled (e.g., a component found via both JSX and value reference) + +### Testing + +```bash +yarn nx run cli:test +``` + +Tests use a fixture-based approach with a `__fixtures__/sample-app/` containing mock `_mock_node_modules` with `.d.ts` declarations. The `usage-report.spec.ts` uses a mock `AstParser` for isolated unit testing. diff --git a/tools/cli/src/commands/report/__fixtures__/sample-app/_mock_node_modules/@proj/react-components/index.d.ts b/tools/cli/src/commands/report/__fixtures__/sample-app/_mock_node_modules/@proj/react-components/index.d.ts new file mode 100644 index 00000000000000..8c9ee3a585506f --- /dev/null +++ b/tools/cli/src/commands/report/__fixtures__/sample-app/_mock_node_modules/@proj/react-components/index.d.ts @@ -0,0 +1,84 @@ +import * as React from 'react'; + +// Component — function returning JSX.Element +export declare function Button(props: ButtonProps): JSX.Element; + +// Component — function returning JSX.Element +export declare function Input(props: InputProps): JSX.Element; + +// Component — function returning JSX.Element +export declare function FluentProvider(props: FluentProviderProps): JSX.Element; + +// Component — function returning JSX.Element +export declare function Tooltip(props: TooltipProps): JSX.Element; + +// Hook +export declare function useId(prefix?: string): string; + +// Hook +export declare function useToastController(): { dispatchToast: () => void }; + +// Hook with options +export declare function useArrowNavigationGroup(options?: { + axis?: 'vertical' | 'horizontal' | 'grid'; + circular?: boolean; + memorizeCurrent?: boolean; + tabbable?: boolean; + unstable_hasDefault?: boolean; +}): Record; + +// Pure type — interface +export interface ButtonProps { + appearance?: 'primary' | 'secondary'; + size?: 'small' | 'medium' | 'large'; + icon?: JSX.Element; + disabled?: boolean; + children?: React.ReactNode; +} + +// Pure type — interface +export interface InputProps { + placeholder?: string; + appearance?: 'outline' | 'filled'; + contentBefore?: JSX.Element; +} + +// Pure type — interface +export interface FluentProviderProps { + theme?: Record; + children?: React.ReactNode; +} + +// Pure type — interface +export interface TooltipProps { + content?: string; + relationship?: string; + children?: React.ReactNode; +} + +// Other — constant (not a component, not a type) +export declare const tokens: Record; + +// Other — constant (theme object) +export declare const webLightTheme: Record; + +// Other — constant (PascalCase but NOT a component) +export declare const AzureLightTheme: Record; + +// Other — utility function +export declare function makeStyles>>( + styles: T, +): () => Record; + +// Generic type — for testing generic type reference tracking +export type ColumnDef = { + id: string; + header: string; + cell: (data: TData) => React.ReactNode; +}; + +// Other — constant (PascalCase, looks like component but is a string constant) +export declare const Tab: string; + +// Other — constant +export declare const Enter: string; diff --git a/tools/cli/src/commands/report/__fixtures__/sample-app/_mock_node_modules/@proj/react-components/package.json b/tools/cli/src/commands/report/__fixtures__/sample-app/_mock_node_modules/@proj/react-components/package.json new file mode 100644 index 00000000000000..b8a99811f361ae --- /dev/null +++ b/tools/cli/src/commands/report/__fixtures__/sample-app/_mock_node_modules/@proj/react-components/package.json @@ -0,0 +1,5 @@ +{ + "name": "@proj/react-components", + "version": "9.0.0-fixture", + "types": "index.d.ts" +} diff --git a/tools/cli/src/commands/report/__fixtures__/sample-app/_mock_node_modules/@types/react/index.d.ts b/tools/cli/src/commands/report/__fixtures__/sample-app/_mock_node_modules/@types/react/index.d.ts new file mode 100644 index 00000000000000..0f909cece38086 --- /dev/null +++ b/tools/cli/src/commands/report/__fixtures__/sample-app/_mock_node_modules/@types/react/index.d.ts @@ -0,0 +1,26 @@ +declare namespace React { + type ReactNode = string | number | boolean | null | undefined | React.ReactElement; + interface ReactElement { + type: string; + props: Record; + key: string | null; + } + type FC

= (props: P) => ReactElement | null; + type FunctionComponent

= FC

; +} + +declare namespace JSX { + interface Element extends React.ReactElement {} + interface IntrinsicElements { + div: Record; + span: Record; + } +} + +declare module 'react' { + export = React; + export as namespace React; +} + +export = React; +export as namespace React; diff --git a/tools/cli/src/commands/report/__fixtures__/sample-app/_mock_node_modules/@types/react/package.json b/tools/cli/src/commands/report/__fixtures__/sample-app/_mock_node_modules/@types/react/package.json new file mode 100644 index 00000000000000..9272fa42ab5ce7 --- /dev/null +++ b/tools/cli/src/commands/report/__fixtures__/sample-app/_mock_node_modules/@types/react/package.json @@ -0,0 +1,5 @@ +{ + "name": "@types/react", + "version": "18.0.0-fixture", + "types": "index.d.ts" +} diff --git a/tools/cli/src/commands/report/__fixtures__/sample-app/package.json b/tools/cli/src/commands/report/__fixtures__/sample-app/package.json new file mode 100644 index 00000000000000..0d1670db6ea46b --- /dev/null +++ b/tools/cli/src/commands/report/__fixtures__/sample-app/package.json @@ -0,0 +1,15 @@ +{ + "name": "sample-app", + "version": "1.0.0", + "dependencies": { + "@proj/react-components": "^9.50.0", + "@proj/react-icons": "^2.0.200", + "react": "^18.2.0", + "@types/react": "^18.2.0", + "typescript": "^5.3.0", + "@griffel/react": "^1.5.0" + }, + "devDependencies": { + "@proj/eslint-plugin": "^1.0.0" + } +} diff --git a/tools/cli/src/commands/report/__fixtures__/sample-app/packages/azure-theme/src/components/AzureLightTheme/AzureLightTheme.ts b/tools/cli/src/commands/report/__fixtures__/sample-app/packages/azure-theme/src/components/AzureLightTheme/AzureLightTheme.ts new file mode 100644 index 00000000000000..e9c1679b22137d --- /dev/null +++ b/tools/cli/src/commands/report/__fixtures__/sample-app/packages/azure-theme/src/components/AzureLightTheme/AzureLightTheme.ts @@ -0,0 +1,4 @@ +export const AzureLightTheme: Record = { + colorBrandBackground: '#0078d4', + colorBrandForeground1: '#0078d4', +}; diff --git a/tools/cli/src/commands/report/__fixtures__/sample-app/packages/azure-theme/src/components/AzureLightTheme/index.ts b/tools/cli/src/commands/report/__fixtures__/sample-app/packages/azure-theme/src/components/AzureLightTheme/index.ts new file mode 100644 index 00000000000000..9c6559211df2dc --- /dev/null +++ b/tools/cli/src/commands/report/__fixtures__/sample-app/packages/azure-theme/src/components/AzureLightTheme/index.ts @@ -0,0 +1 @@ +export { AzureLightTheme } from './AzureLightTheme'; diff --git a/tools/cli/src/commands/report/__fixtures__/sample-app/packages/azure-theme/src/index.ts b/tools/cli/src/commands/report/__fixtures__/sample-app/packages/azure-theme/src/index.ts new file mode 100644 index 00000000000000..20f9e42015c008 --- /dev/null +++ b/tools/cli/src/commands/report/__fixtures__/sample-app/packages/azure-theme/src/index.ts @@ -0,0 +1 @@ +export { AzureLightTheme } from './components/AzureLightTheme'; diff --git a/tools/cli/src/commands/report/__fixtures__/sample-app/src/aliased-imports.tsx b/tools/cli/src/commands/report/__fixtures__/sample-app/src/aliased-imports.tsx new file mode 100644 index 00000000000000..1562119be1a7c4 --- /dev/null +++ b/tools/cli/src/commands/report/__fixtures__/sample-app/src/aliased-imports.tsx @@ -0,0 +1,10 @@ +import { Button as FluentButton, useToastController as useToast } from '@proj/react-components'; +import type { ButtonProps as FluentButtonProps, ColumnDef as FluentColumnDef } from '@proj/react-components'; + +export type AliasedColumn = FluentColumnDef; + +export function AliasedImports(props: FluentButtonProps) { + const { dispatchToast } = useToast(); + + return {props.children}; +} diff --git a/tools/cli/src/commands/report/__fixtures__/sample-app/src/basic-usage.tsx b/tools/cli/src/commands/report/__fixtures__/sample-app/src/basic-usage.tsx new file mode 100644 index 00000000000000..f6256e36a9fa5b --- /dev/null +++ b/tools/cli/src/commands/report/__fixtures__/sample-app/src/basic-usage.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { Button, Input, makeStyles, tokens } from '@proj/react-components'; +import { useId } from '@proj/react-components'; +import { SearchRegular } from '@proj/react-icons'; + +const useStyles = makeStyles({ + root: { + display: 'flex', + gap: tokens.spacingHorizontalM, + }, +}); + +export const SearchForm = () => { + const inputId = useId('search-input'); + const styles = useStyles(); + + return ( +

+ } /> + + +
+ ); +}; diff --git a/tools/cli/src/commands/report/__fixtures__/sample-app/src/hook-args.tsx b/tools/cli/src/commands/report/__fixtures__/sample-app/src/hook-args.tsx new file mode 100644 index 00000000000000..e6350c482c71e0 --- /dev/null +++ b/tools/cli/src/commands/report/__fixtures__/sample-app/src/hook-args.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { useArrowNavigationGroup, useId } from '@proj/react-components'; + +const circular = true; + +export const NavGroup = () => { + const id = useId('nav'); + + // Explicit property assignments — values should be captured as literals + const attrs = useArrowNavigationGroup({ + axis: 'vertical', + memorizeCurrent: true, + unstable_hasDefault: true, + }); + + // Mix of explicit and shorthand — `circular` is shorthand (value = variable ref) + const attrs2 = useArrowNavigationGroup({ + axis: 'horizontal', + circular, + }); + + return ( +
+ Navigation group +
+ ); +}; diff --git a/tools/cli/src/commands/report/__fixtures__/sample-app/src/mixed-imports.tsx b/tools/cli/src/commands/report/__fixtures__/sample-app/src/mixed-imports.tsx new file mode 100644 index 00000000000000..5b664db9bcfb21 --- /dev/null +++ b/tools/cli/src/commands/report/__fixtures__/sample-app/src/mixed-imports.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { FluentProvider, webLightTheme, Tooltip, useToastController } from '@proj/react-components'; +import { makeStyles } from '@griffel/react'; + +const useStyles = makeStyles({ + wrapper: { + padding: '20px', + }, +}); + +export const App: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const styles = useStyles(); + const { dispatchToast } = useToastController(); + + return ( + +
+ + {children} + +
+
+ ); +}; diff --git a/tools/cli/src/commands/report/__fixtures__/sample-app/src/path-alias-imports.tsx b/tools/cli/src/commands/report/__fixtures__/sample-app/src/path-alias-imports.tsx new file mode 100644 index 00000000000000..2908ac565e5d48 --- /dev/null +++ b/tools/cli/src/commands/report/__fixtures__/sample-app/src/path-alias-imports.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { AzureLightTheme } from '@sample/azure-theme'; +import { FluentProvider } from '@proj/react-components'; + +export const ThemedApp = () => ( + + Hello + +); diff --git a/tools/cli/src/commands/report/__fixtures__/sample-app/src/type-imports.ts b/tools/cli/src/commands/report/__fixtures__/sample-app/src/type-imports.ts new file mode 100644 index 00000000000000..e28df445cd98f5 --- /dev/null +++ b/tools/cli/src/commands/report/__fixtures__/sample-app/src/type-imports.ts @@ -0,0 +1,12 @@ +import type { ButtonProps, InputProps } from '@proj/react-components'; +import type { FluentIcon } from '@proj/react-icons'; + +export type CustomButtonProps = ButtonProps & { + tooltip?: string; +}; + +export type SearchInputProps = InputProps & { + onSearch?: (value: string) => void; +}; + +export type IconType = FluentIcon; diff --git a/tools/cli/src/commands/report/__fixtures__/sample-app/src/type-refs.tsx b/tools/cli/src/commands/report/__fixtures__/sample-app/src/type-refs.tsx new file mode 100644 index 00000000000000..c3bfc266df3e4a --- /dev/null +++ b/tools/cli/src/commands/report/__fixtures__/sample-app/src/type-refs.tsx @@ -0,0 +1,20 @@ +import { Button, FluentProvider } from '@proj/react-components'; +import type { ButtonProps, ColumnDef } from '@proj/react-components'; + +type MyButtonProps = ButtonProps & { + tooltip?: string; +}; + +const meta: { component: typeof Button } = { + component: Button, +}; + +// Generic type usage — ColumnDef with type argument +type UserColumn = ColumnDef<{ name: string; age: number }>; +type ProductColumn = ColumnDef<{ id: number; price: number }>; + +export const App = () => ( + + + +); diff --git a/tools/cli/src/commands/report/__fixtures__/sample-app/tsconfig.json b/tools/cli/src/commands/report/__fixtures__/sample-app/tsconfig.json new file mode 100644 index 00000000000000..a8780a6af0f916 --- /dev/null +++ b/tools/cli/src/commands/report/__fixtures__/sample-app/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "node", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "baseUrl": ".", + "paths": { + "@sample/azure-theme": ["packages/azure-theme/src/index.ts"], + "@proj/react-components": ["_mock_node_modules/@proj/react-components/index.d.ts"], + "react": ["_mock_node_modules/@types/react/index.d.ts"] + }, + "typeRoots": [] + }, + "include": ["src/**/*"] +} diff --git a/tools/cli/src/commands/report/commands/info.spec.ts b/tools/cli/src/commands/report/commands/info.spec.ts new file mode 100644 index 00000000000000..bb865f436baac8 --- /dev/null +++ b/tools/cli/src/commands/report/commands/info.spec.ts @@ -0,0 +1,27 @@ +jest.mock('../impl/info-report', () => ({ + runInfoReport: jest.fn().mockResolvedValue(undefined), +})); + +describe('report info command', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call runInfoReport with no output', async () => { + const infoCommand = (await import('./info')).default; + + await (infoCommand.handler as Function)({ _: ['report', 'info'], $0: 'fluentui-cli' }); + + const { runInfoReport } = require('../impl/info-report'); + expect(runInfoReport).toHaveBeenCalledWith(undefined); + }); + + it('should pass output to runInfoReport', async () => { + const infoCommand = (await import('./info')).default; + + await (infoCommand.handler as Function)({ _: ['report', 'info'], $0: 'fluentui-cli', output: 'info.txt' }); + + const { runInfoReport } = require('../impl/info-report'); + expect(runInfoReport).toHaveBeenCalledWith('info.txt'); + }); +}); diff --git a/tools/cli/src/commands/report/commands/info.ts b/tools/cli/src/commands/report/commands/info.ts new file mode 100644 index 00000000000000..981477821ec416 --- /dev/null +++ b/tools/cli/src/commands/report/commands/info.ts @@ -0,0 +1,23 @@ +import type { CommandModule } from 'yargs'; + +import type { InfoArgs } from '../impl/types'; + +const infoCommand: CommandModule<{}, InfoArgs> = { + command: 'info', + describe: 'Quick package & environment summary for issue reporting', + builder: yargs => + yargs + .option('output', { + alias: 'o', + type: 'string', + describe: 'Output file path (default: stdout)', + }) + .version(false) + .help(), + handler: async argv => { + const { runInfoReport } = await import('../impl/info-report'); + return runInfoReport(argv.output); + }, +}; + +export default infoCommand; diff --git a/tools/cli/src/commands/report/commands/usage.spec.ts b/tools/cli/src/commands/report/commands/usage.spec.ts new file mode 100644 index 00000000000000..d831d8060a3e29 --- /dev/null +++ b/tools/cli/src/commands/report/commands/usage.spec.ts @@ -0,0 +1,78 @@ +jest.mock('../impl/usage-report', () => ({ + runUsageReport: jest.fn().mockResolvedValue(undefined), +})); + +describe('report usage command', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call runUsageReport with default args', async () => { + const usageCommand = (await import('./usage')).default; + + await (usageCommand.handler as Function)({ + _: ['report', 'usage'], + $0: 'fluentui-cli', + reporter: 'json', + }); + + const { runUsageReport } = require('../impl/usage-report'); + expect(runUsageReport).toHaveBeenCalledWith(undefined, 'json', undefined, undefined, undefined); + }); + + it('should pass path and reporter to runUsageReport', async () => { + const usageCommand = (await import('./usage')).default; + + await (usageCommand.handler as Function)({ + _: ['report', 'usage'], + $0: 'fluentui-cli', + path: '/some/path', + reporter: 'markdown', + }); + + const { runUsageReport } = require('../impl/usage-report'); + expect(runUsageReport).toHaveBeenCalledWith('/some/path', 'markdown', undefined, undefined, undefined); + }); + + it('should pass html reporter to runUsageReport', async () => { + const usageCommand = (await import('./usage')).default; + + await (usageCommand.handler as Function)({ + _: ['report', 'usage'], + $0: 'fluentui-cli', + reporter: 'html', + }); + + const { runUsageReport } = require('../impl/usage-report'); + expect(runUsageReport).toHaveBeenCalledWith(undefined, 'html', undefined, undefined, undefined); + }); + + it('should pass include and exclude to runUsageReport', async () => { + const usageCommand = (await import('./usage')).default; + + await (usageCommand.handler as Function)({ + _: ['report', 'usage'], + $0: 'fluentui-cli', + reporter: 'json', + include: ['src/**'], + exclude: ['**/*.test.*'], + }); + + const { runUsageReport } = require('../impl/usage-report'); + expect(runUsageReport).toHaveBeenCalledWith(undefined, 'json', ['src/**'], ['**/*.test.*'], undefined); + }); + + it('should pass output to runUsageReport', async () => { + const usageCommand = (await import('./usage')).default; + + await (usageCommand.handler as Function)({ + _: ['report', 'usage'], + $0: 'fluentui-cli', + reporter: 'json', + output: 'report.json', + }); + + const { runUsageReport } = require('../impl/usage-report'); + expect(runUsageReport).toHaveBeenCalledWith(undefined, 'json', undefined, undefined, 'report.json'); + }); +}); diff --git a/tools/cli/src/commands/report/commands/usage.ts b/tools/cli/src/commands/report/commands/usage.ts new file mode 100644 index 00000000000000..9aabdb9861feaf --- /dev/null +++ b/tools/cli/src/commands/report/commands/usage.ts @@ -0,0 +1,45 @@ +import type { CommandModule } from 'yargs'; + +import type { UsageArgs } from '../impl/types'; + +const usageCommand: CommandModule<{}, UsageArgs> = { + command: 'usage', + describe: 'Deep codebase usage analysis of Fluent UI APIs', + builder: yargs => + yargs + .option('path', { + alias: 'p', + type: 'string', + describe: 'Root path for file traversal (defaults to git root)', + }) + .option('reporter', { + alias: 'r', + type: 'string', + choices: ['json', 'markdown', 'html'] as const, + default: 'json' as const, + describe: 'Output format', + }) + .option('include', { + type: 'string', + array: true, + describe: 'Glob patterns to include files', + }) + .option('exclude', { + type: 'string', + array: true, + describe: 'Glob patterns to exclude files', + }) + .option('output', { + alias: 'o', + type: 'string', + describe: 'Output file path (default: stdout)', + }) + .version(false) + .help(), + handler: async argv => { + const { runUsageReport } = await import('../impl/usage-report'); + return runUsageReport(argv.path, argv.reporter, argv.include, argv.exclude, argv.output); + }, +}; + +export default usageCommand; diff --git a/tools/cli/src/commands/report/impl/ast-parser.spec.ts b/tools/cli/src/commands/report/impl/ast-parser.spec.ts new file mode 100644 index 00000000000000..25be5e384335c8 --- /dev/null +++ b/tools/cli/src/commands/report/impl/ast-parser.spec.ts @@ -0,0 +1,362 @@ +import * as path from 'node:path'; + +import { TsMorphAstParser } from './ast-parser'; + +const FIXTURES_DIR = path.join(__dirname, '..', '__fixtures__', 'sample-app', 'src'); +const FIXTURES_ROOT = path.join(__dirname, '..', '__fixtures__', 'sample-app'); +const TSCONFIG_PATH = path.join(FIXTURES_ROOT, 'tsconfig.json'); + +describe('TsMorphAstParser', () => { + let parser: TsMorphAstParser; + + beforeAll(() => { + parser = new TsMorphAstParser(); + parser.createProject( + [ + path.join(FIXTURES_DIR, 'basic-usage.tsx'), + path.join(FIXTURES_DIR, 'type-imports.ts'), + path.join(FIXTURES_DIR, 'mixed-imports.tsx'), + path.join(FIXTURES_DIR, 'type-refs.tsx'), + ], + TSCONFIG_PATH, + ); + }); + + describe('getSourceFiles', () => { + it('should return all added source files', () => { + const files = parser.getSourceFiles(); + expect(files).toHaveLength(4); + expect(files.some(f => f.endsWith('basic-usage.tsx'))).toBe(true); + expect(files.some(f => f.endsWith('type-imports.ts'))).toBe(true); + expect(files.some(f => f.endsWith('mixed-imports.tsx'))).toBe(true); + expect(files.some(f => f.endsWith('type-refs.tsx'))).toBe(true); + }); + + it('should not include resolved dependency files (e.g., .d.ts from node_modules)', () => { + const files = parser.getSourceFiles(); + const dtsFiles = files.filter(f => f.endsWith('.d.ts')); + const nodeModulesFiles = files.filter(f => f.includes('node_modules')); + expect(dtsFiles).toHaveLength(0); + expect(nodeModulesFiles).toHaveLength(0); + }); + }); + + describe('getImportDeclarations', () => { + it('should extract named imports from basic-usage.tsx', () => { + const filePath = path.join(FIXTURES_DIR, 'basic-usage.tsx'); + const imports = parser.getImportDeclarations(filePath); + + const fluentImport = imports.find(i => i.moduleSpecifier === '@proj/react-components' && !i.isTypeOnly); + expect(fluentImport).toBeDefined(); + expect(fluentImport!.namedImports).toEqual(expect.arrayContaining(['Button', 'Input', 'makeStyles', 'tokens'])); + }); + + it('should detect type-only imports from type-imports.ts', () => { + const filePath = path.join(FIXTURES_DIR, 'type-imports.ts'); + const imports = parser.getImportDeclarations(filePath); + + const typeImport = imports.find(i => i.moduleSpecifier === '@proj/react-components'); + expect(typeImport).toBeDefined(); + expect(typeImport!.isTypeOnly).toBe(true); + expect(typeImport!.namedImports).toEqual(expect.arrayContaining(['ButtonProps', 'InputProps'])); + }); + + it('should extract imports from @proj/react-icons', () => { + const filePath = path.join(FIXTURES_DIR, 'basic-usage.tsx'); + const imports = parser.getImportDeclarations(filePath); + + const iconsImport = imports.find(i => i.moduleSpecifier === '@proj/react-icons'); + expect(iconsImport).toBeDefined(); + expect(iconsImport!.namedImports).toContain('SearchRegular'); + }); + + it('should return empty array for unknown file', () => { + const imports = parser.getImportDeclarations('/nonexistent/file.ts'); + expect(imports).toEqual([]); + }); + + it('should preserve exported and local names for aliased imports', () => { + const filePath = path.join(FIXTURES_DIR, 'aliased-imports.tsx'); + const aliasParser = new TsMorphAstParser(); + aliasParser.createProject([filePath], TSCONFIG_PATH); + + const imports = aliasParser.getImportDeclarations(filePath); + const fluentImport = imports.find(i => i.moduleSpecifier === '@proj/react-components' && !i.isTypeOnly); + const typeImport = imports.find(i => i.moduleSpecifier === '@proj/react-components' && i.isTypeOnly); + + expect(fluentImport?.namedImports).toEqual(['Button', 'useToastController']); + expect(fluentImport?.localNames).toEqual({ Button: 'FluentButton', useToastController: 'useToast' }); + expect(typeImport?.namedImports).toEqual(['ButtonProps', 'ColumnDef']); + expect(typeImport?.localNames).toEqual({ ButtonProps: 'FluentButtonProps', ColumnDef: 'FluentColumnDef' }); + expect(aliasParser.getJsxElementUsages(filePath)[0].componentName).toBe('Button'); + expect(aliasParser.getCallExpressionUsages(filePath)[0].functionName).toBe('useToastController'); + expect(aliasParser.getTypeReferenceUsages(filePath)[0].symbolName).toBe('ColumnDef'); + }); + }); + + describe('getJsxElementUsages', () => { + it('should detect JSX component usages with props', () => { + const filePath = path.join(FIXTURES_DIR, 'basic-usage.tsx'); + const usages = parser.getJsxElementUsages(filePath); + + const buttonUsages = usages.filter(u => u.componentName === 'Button'); + expect(buttonUsages.length).toBeGreaterThanOrEqual(2); + + const primaryButton = buttonUsages.find(u => u.props.appearance === 'primary'); + expect(primaryButton).toBeDefined(); + expect(primaryButton!.moduleSpecifier).toBe('@proj/react-components'); + expect(primaryButton!.props.size).toBe('medium'); + }); + + it('should detect Input component with props', () => { + const filePath = path.join(FIXTURES_DIR, 'basic-usage.tsx'); + const usages = parser.getJsxElementUsages(filePath); + + const inputUsages = usages.filter(u => u.componentName === 'Input'); + expect(inputUsages).toHaveLength(1); + expect(inputUsages[0].props.placeholder).toBe('Search...'); + expect(inputUsages[0].props.appearance).toBe('outline'); + }); + + it('should detect components from mixed-imports.tsx', () => { + const filePath = path.join(FIXTURES_DIR, 'mixed-imports.tsx'); + const usages = parser.getJsxElementUsages(filePath); + + const fluentProvider = usages.find(u => u.componentName === 'FluentProvider'); + expect(fluentProvider).toBeDefined(); + expect(fluentProvider!.moduleSpecifier).toBe('@proj/react-components'); + + const tooltip = usages.find(u => u.componentName === 'Tooltip'); + expect(tooltip).toBeDefined(); + expect(tooltip!.props.content).toBe('App wrapper'); + expect(tooltip!.props.relationship).toBe('description'); + }); + + it('should return empty array for file without JSX', () => { + const filePath = path.join(FIXTURES_DIR, 'type-imports.ts'); + const usages = parser.getJsxElementUsages(filePath); + expect(usages).toEqual([]); + }); + }); + + describe('getCallExpressionUsages', () => { + it('should detect hook calls in basic-usage.tsx', () => { + const filePath = path.join(FIXTURES_DIR, 'basic-usage.tsx'); + const calls = parser.getCallExpressionUsages(filePath); + + const useIdCall = calls.find(c => c.functionName === 'useId'); + expect(useIdCall).toBeDefined(); + expect(useIdCall!.moduleSpecifier).toBe('@proj/react-components'); + }); + + it('should detect function calls in mixed-imports.tsx', () => { + const filePath = path.join(FIXTURES_DIR, 'mixed-imports.tsx'); + const calls = parser.getCallExpressionUsages(filePath); + + const makeStylesCall = calls.find(c => c.functionName === 'makeStyles'); + expect(makeStylesCall).toBeDefined(); + expect(makeStylesCall!.moduleSpecifier).toBe('@griffel/react'); + }); + }); + + describe('getTypeReferenceUsages', () => { + it('should detect typeof references', () => { + const filePath = path.join(FIXTURES_DIR, 'type-refs.tsx'); + const usages = parser.getTypeReferenceUsages(filePath); + + const typeofButton = usages.find(u => u.symbolName === 'Button' && u.kind === 'typeof'); + expect(typeofButton).toBeDefined(); + expect(typeofButton!.moduleSpecifier).toBe('@proj/react-components'); + }); + + it('should detect generic type references with type arguments', () => { + const filePath = path.join(FIXTURES_DIR, 'type-refs.tsx'); + const usages = parser.getTypeReferenceUsages(filePath); + + const genericUsages = usages.filter(u => u.symbolName === 'ColumnDef' && u.kind === 'generic'); + expect(genericUsages).toHaveLength(2); + expect(genericUsages[0].moduleSpecifier).toBe('@proj/react-components'); + expect(genericUsages[0].typeArgs).toBeDefined(); + expect(genericUsages[0].typeArgs!.length).toBe(1); + }); + + it('should return empty for file without type references', () => { + const filePath = path.join(FIXTURES_DIR, 'type-imports.ts'); + const usages = parser.getTypeReferenceUsages(filePath); + + // type-imports.ts only has `import type` but no typeof or generic usage + const typeofUsages = usages.filter(u => u.kind === 'typeof'); + expect(typeofUsages).toHaveLength(0); + }); + }); + + describe('getValueReferenceUsages', () => { + it('should detect value references to imported symbols', () => { + const filePath = path.join(FIXTURES_DIR, 'type-refs.tsx'); + const usages = parser.getValueReferenceUsages(filePath); + + // Button is used as a value in `component: Button` + const buttonRef = usages.find(u => u.symbolName === 'Button'); + expect(buttonRef).toBeDefined(); + expect(buttonRef!.moduleSpecifier).toBe('@proj/react-components'); + }); + + it('should not include JSX tag names in value references', () => { + const filePath = path.join(FIXTURES_DIR, 'basic-usage.tsx'); + const usages = parser.getValueReferenceUsages(filePath); + + // Button and Input are used as JSX, not value references (except contentBefore={} style) + // JSX tag names should be excluded + const buttonJsxRef = usages.filter(u => u.symbolName === 'Button'); + // Button only appears in JSX tags, should not be in value refs + expect(buttonJsxRef).toHaveLength(0); + }); + }); + + describe('classifySymbol', () => { + it('should classify function components returning JSX as "component"', () => { + const filePath = path.join(FIXTURES_DIR, 'basic-usage.tsx'); + + expect(parser.classifySymbol(filePath, 'Button', '@proj/react-components')).toBe('component'); + expect(parser.classifySymbol(filePath, 'Input', '@proj/react-components')).toBe('component'); + }); + + it('should classify hooks as "hook"', () => { + const filePath = path.join(FIXTURES_DIR, 'basic-usage.tsx'); + + expect(parser.classifySymbol(filePath, 'useId', '@proj/react-components')).toBe('hook'); + }); + + it('should classify interfaces/types as "type"', () => { + const filePath = path.join(FIXTURES_DIR, 'type-imports.ts'); + + expect(parser.classifySymbol(filePath, 'ButtonProps', '@proj/react-components')).toBe('type'); + expect(parser.classifySymbol(filePath, 'InputProps', '@proj/react-components')).toBe('type'); + }); + + it('should classify constants as "other" even if PascalCase', () => { + const filePath = path.join(FIXTURES_DIR, 'mixed-imports.tsx'); + + expect(parser.classifySymbol(filePath, 'webLightTheme', '@proj/react-components')).toBe('other'); + }); + + it('should classify hooks from mixed imports correctly', () => { + const filePath = path.join(FIXTURES_DIR, 'mixed-imports.tsx'); + + expect(parser.classifySymbol(filePath, 'useToastController', '@proj/react-components')).toBe('hook'); + }); + + it('should fall back to "unknown" for unresolvable symbols', () => { + const filePath = path.join(FIXTURES_DIR, 'basic-usage.tsx'); + + // Symbol not imported in this file + expect(parser.classifySymbol(filePath, 'NonExistent', 'non-existent-module')).toBe('unknown'); + }); + + it('should classify unresolvable symbols as "unknown" instead of guessing', () => { + const filePath = path.join(FIXTURES_DIR, 'basic-usage.tsx'); + + // These use fallback since they may not resolve from the import + // The fallback now returns 'unknown' for all unresolvable symbols + const freshParser = new TsMorphAstParser(); + freshParser.createProject([path.join(FIXTURES_DIR, 'basic-usage.tsx')]); + + expect(freshParser.classifySymbol(filePath, 'CustomButtonProps', 'nonexistent-module')).toBe('unknown'); + expect(freshParser.classifySymbol(filePath, 'IColor', 'nonexistent-module')).toBe('unknown'); + expect(freshParser.classifySymbol(filePath, 'RowRenderer', 'nonexistent-module')).toBe('unknown'); + expect(freshParser.classifySymbol(filePath, 'AzureLightTheme', 'nonexistent-module')).toBe('unknown'); + }); + }); + + describe('error handling', () => { + it('should throw if createProject was not called', () => { + const freshParser = new TsMorphAstParser(); + expect(() => freshParser.getSourceFiles()).toThrow('call createProject()'); + }); + }); + + describe('describeUnknownSymbol', () => { + it('should describe hook-like names', () => { + expect(parser.describeUnknownSymbol('useCustomHook')).toBe('Likely a React hook (use* naming convention)'); + }); + + it('should describe type-like names with known suffixes', () => { + expect(parser.describeUnknownSymbol('ButtonProps')).toBe('Likely a type/interface (*Props naming convention)'); + expect(parser.describeUnknownSymbol('RowRenderer')).toBe('Likely a type/interface (*Renderer naming convention)'); + }); + + it('should describe I-prefixed names as interfaces', () => { + expect(parser.describeUnknownSymbol('IColor')).toBe('Likely an interface (I* naming convention)'); + }); + + it('should describe PascalCase names generically', () => { + expect(parser.describeUnknownSymbol('AzureLightTheme')).toBe( + 'PascalCase symbol — could be a component, constant, or type', + ); + }); + + it('should return default description for other names', () => { + expect(parser.describeUnknownSymbol('someThing')).toBe('Unresolved symbol — .d.ts not available'); + }); + }); + + describe('hook argument value extraction', () => { + let hookParser: TsMorphAstParser; + + beforeAll(() => { + hookParser = new TsMorphAstParser(); + hookParser.createProject([path.join(FIXTURES_DIR, 'hook-args.tsx')], TSCONFIG_PATH); + }); + + it('should extract literal property values from hook calls, not property names', () => { + const filePath = path.join(FIXTURES_DIR, 'hook-args.tsx'); + const calls = hookParser.getCallExpressionUsages(filePath); + + const navCalls = calls.filter(c => c.functionName === 'useArrowNavigationGroup'); + expect(navCalls.length).toBe(2); + + // First call: useArrowNavigationGroup({ axis: 'vertical', memorizeCurrent: true, unstable_hasDefault: true }) + const firstCall = navCalls[0]; + expect(firstCall.args.axis).toBe("'vertical'"); + expect(firstCall.args.memorizeCurrent).toBe('true'); + expect(firstCall.args.unstable_hasDefault).toBe('true'); + + // Second call: useArrowNavigationGroup({ axis: 'horizontal', circular }) + const secondCall = navCalls[1]; + expect(secondCall.args.axis).toBe("'horizontal'"); + // Shorthand property: value is the variable name (can't statically resolve) + expect(secondCall.args.circular).toBe('circular'); + }); + + it('should not return resolved .d.ts files from getSourceFiles', () => { + const files = hookParser.getSourceFiles(); + expect(files).toHaveLength(1); + expect(files[0]).toContain('hook-args.tsx'); + }); + }); + + describe('path alias resolution', () => { + let aliasParser: TsMorphAstParser; + + beforeAll(() => { + aliasParser = new TsMorphAstParser(); + aliasParser.createProject([path.join(FIXTURES_DIR, 'path-alias-imports.tsx')], TSCONFIG_PATH); + }); + + it('should classify path-aliased symbols resolving to .ts source as "unknown"', () => { + const filePath = path.join(FIXTURES_DIR, 'path-alias-imports.tsx'); + + // Path aliases resolve to .ts source files, not .d.ts — classified as unknown + const result = aliasParser.classifySymbol(filePath, 'AzureLightTheme', '@sample/azure-theme'); + expect(result).toBe('unknown'); + }); + + it('should still resolve .d.ts-based symbols correctly', () => { + const filePath = path.join(FIXTURES_DIR, 'path-alias-imports.tsx'); + + // FluentProvider comes from node_modules .d.ts — should resolve as component + const result = aliasParser.classifySymbol(filePath, 'FluentProvider', '@proj/react-components'); + expect(result).toBe('component'); + }); + }); +}); diff --git a/tools/cli/src/commands/report/impl/ast-parser.ts b/tools/cli/src/commands/report/impl/ast-parser.ts new file mode 100644 index 00000000000000..c027c73a1869d7 --- /dev/null +++ b/tools/cli/src/commands/report/impl/ast-parser.ts @@ -0,0 +1,597 @@ +import { + Project, + SyntaxKind, + Node, + type SourceFile, + type JsxOpeningElement, + type JsxSelfClosingElement, +} from 'ts-morph'; + +import type { + AstParser, + ImportInfo, + JsxUsageInfo, + CallUsageInfo, + SymbolClassification, + TypeRefUsageInfo, +} from './types'; + +/** Type names that indicate a JSX return type. */ +const JSX_TYPE_NAMES = new Set([ + 'Element', + 'ReactElement', + 'ReactNode', + 'JSX.Element', + 'React.ReactElement', + 'React.ReactNode', +]); + +/** Name patterns that strongly suggest a pure type (interface/type alias). */ +const TYPE_NAME_SUFFIXES = [ + 'Props', + 'State', + 'Slots', + 'Type', + 'Data', + 'Event', + 'Handler', + 'Params', + 'Renderer', + 'Element', + 'Colors', + 'Geometry', +]; + +/** + * ts-morph implementation of the AstParser interface. + * Can be replaced with raw TypeScript compiler API or another parser. + */ +export class TsMorphAstParser implements AstParser { + private project: Project | null = null; + + /** Cache classification results per (moduleSpecifier::symbolName) for consistency across files. */ + private classificationCache = new Map(); + + /** The original user source file paths (excludes resolved dependencies). */ + private userFilePaths = new Set(); + + public createProject(filePaths: string[], tsConfigPath?: string, rootPath?: string): void { + if (tsConfigPath) { + this.project = new Project({ tsConfigFilePath: tsConfigPath, skipAddingFilesFromTsConfig: true }); + } else { + this.project = new Project({ + compilerOptions: { + jsx: 2 /* JsxEmit.React */, + allowJs: true, + moduleResolution: 2 /* ModuleResolutionKind.NodeJs */, + ...(rootPath ? { baseUrl: rootPath } : {}), + }, + skipAddingFilesFromTsConfig: true, + }); + } + + this.project.addSourceFilesAtPaths(filePaths); + + // Track which files are the user's source files before resolving dependencies + this.userFilePaths = new Set(this.project.getSourceFiles().map(sf => sf.getFilePath())); + + // Resolve import dependencies so ts-morph can follow path aliases + // and node_modules .d.ts files for symbol classification. + this.project.resolveSourceFileDependencies(); + + this.classificationCache.clear(); + } + + public getSourceFiles(): string[] { + this._ensureProject(); + // Only return user source files, not resolved library dependencies + return this.project!.getSourceFiles() + .map(sf => sf.getFilePath()) + .filter(fp => this.userFilePaths.has(fp)); + } + + public getImportDeclarations(filePath: string): ImportInfo[] { + const sourceFile = this._getSourceFile(filePath); + if (!sourceFile) { + return []; + } + + return sourceFile.getImportDeclarations().map(decl => { + const localNames: Record = {}; + const namedImports = decl.getNamedImports().map(namedImport => { + const importedName = namedImport.getName(); + const localName = namedImport.getAliasNode()?.getText(); + if (localName) { + localNames[importedName] = localName; + } + return importedName; + }); + const defaultImport = decl.getDefaultImport(); + if (defaultImport) { + namedImports.unshift(defaultImport.getText()); + } + + return { + moduleSpecifier: decl.getModuleSpecifierValue(), + namedImports, + localNames: Object.keys(localNames).length > 0 ? localNames : undefined, + isTypeOnly: decl.isTypeOnly(), + }; + }); + } + + public getJsxElementUsages(filePath: string): JsxUsageInfo[] { + const sourceFile = this._getSourceFile(filePath); + if (!sourceFile) { + return []; + } + + const importMap = this._buildImportMap(sourceFile); + const usages: JsxUsageInfo[] = []; + + // Collect JSX opening elements and self-closing elements + const openingElements = sourceFile.getDescendantsOfKind(SyntaxKind.JsxOpeningElement); + const selfClosingElements = sourceFile.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement); + + for (const element of [...openingElements, ...selfClosingElements]) { + const tagName = element.getTagNameNode().getText(); + const importedSymbol = importMap.get(tagName); + + // Only track components that come from tracked imports (PascalCase) + if (importedSymbol && /^[A-Z]/.test(tagName)) { + const props = this._extractJsxProps(element); + usages.push({ + componentName: importedSymbol.importedName, + props, + moduleSpecifier: importedSymbol.moduleSpecifier, + }); + } + } + + return usages; + } + + public getCallExpressionUsages(filePath: string): CallUsageInfo[] { + const sourceFile = this._getSourceFile(filePath); + if (!sourceFile) { + return []; + } + + const importMap = this._buildImportMap(sourceFile); + const usages: CallUsageInfo[] = []; + + const callExpressions = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression); + + for (const call of callExpressions) { + const expression = call.getExpression(); + const functionName = expression.getText(); + const importedSymbol = importMap.get(functionName); + + if (importedSymbol) { + const args = this._extractCallArgs(call); + usages.push({ + functionName: importedSymbol.importedName, + args, + moduleSpecifier: importedSymbol.moduleSpecifier, + }); + } + } + + return usages; + } + + public getTypeReferenceUsages(filePath: string): TypeRefUsageInfo[] { + const sourceFile = this._getSourceFile(filePath); + if (!sourceFile) { + return []; + } + + const importMap = this._buildImportMap(sourceFile); + const usages: TypeRefUsageInfo[] = []; + + // Track typeof X in type positions (TypeQuery nodes) + const typeQueries = sourceFile.getDescendantsOfKind(SyntaxKind.TypeQuery); + for (const tq of typeQueries) { + const exprName = tq.getExprName().getText(); + const importedSymbol = importMap.get(exprName); + if (importedSymbol) { + usages.push({ + symbolName: importedSymbol.importedName, + moduleSpecifier: importedSymbol.moduleSpecifier, + kind: 'typeof', + }); + } + } + + // Track type references with generics (e.g., RowRenderer) + const typeRefs = sourceFile.getDescendantsOfKind(SyntaxKind.TypeReference); + for (const tr of typeRefs) { + const typeName = tr.getTypeName().getText(); + const importedSymbol = importMap.get(typeName); + if (importedSymbol) { + const typeArgs = tr.getTypeArguments().map(ta => ta.getText()); + if (typeArgs.length > 0) { + usages.push({ + symbolName: importedSymbol.importedName, + moduleSpecifier: importedSymbol.moduleSpecifier, + kind: 'generic', + typeArgs, + }); + } + } + } + + return usages; + } + + public getValueReferenceUsages(filePath: string): Array<{ symbolName: string; moduleSpecifier: string }> { + const sourceFile = this._getSourceFile(filePath); + if (!sourceFile) { + return []; + } + + const importMap = this._buildImportMap(sourceFile); + const usages: Array<{ symbolName: string; moduleSpecifier: string }> = []; + + const identifiers = sourceFile.getDescendantsOfKind(SyntaxKind.Identifier); + + for (const ident of identifiers) { + const name = ident.getText(); + const importedSymbol = importMap.get(name); + if (!importedSymbol) { + continue; + } + + const parent = ident.getParent(); + if (!parent) { + continue; + } + + // Skip identifiers that are part of import declarations + if (ident.getFirstAncestorByKind(SyntaxKind.ImportDeclaration)) { + continue; + } + + // Skip JSX tag names (already tracked by getJsxElementUsages) + const parentKind = parent.getKind(); + if ( + parentKind === SyntaxKind.JsxOpeningElement || + parentKind === SyntaxKind.JsxSelfClosingElement || + parentKind === SyntaxKind.JsxClosingElement + ) { + continue; + } + + // Skip call expression callees (already tracked by getCallExpressionUsages) + if (parentKind === SyntaxKind.CallExpression) { + const callExpr = parent.asKind(SyntaxKind.CallExpression)!; + if (callExpr.getExpression() === ident) { + continue; + } + } + + // Skip type positions (typeof, type annotations, etc.) + if (ident.getFirstAncestorByKind(SyntaxKind.TypeQuery)) { + continue; + } + if (ident.getFirstAncestorByKind(SyntaxKind.TypeReference)) { + continue; + } + + usages.push({ symbolName: importedSymbol.importedName, moduleSpecifier: importedSymbol.moduleSpecifier }); + } + + return usages; + } + + public classifySymbol(filePath: string, symbolName: string, moduleSpecifier: string): SymbolClassification { + // Check cache first for consistency across files + const cacheKey = `${moduleSpecifier}::${symbolName}`; + const cached = this.classificationCache.get(cacheKey); + if (cached) { + return cached; + } + + // Quick hook check by naming convention (reliable enough) + if (/^use[A-Z]/.test(symbolName)) { + this.classificationCache.set(cacheKey, 'hook'); + return 'hook'; + } + + const sourceFile = this._getSourceFile(filePath); + if (!sourceFile) { + const result = this._fallbackClassify(symbolName); + this.classificationCache.set(cacheKey, result); + return result; + } + + // Find the import declaration that imports this symbol from the given module + const importDecl = sourceFile.getImportDeclarations().find(d => d.getModuleSpecifierValue() === moduleSpecifier); + if (!importDecl) { + const result = this._fallbackClassify(symbolName); + this.classificationCache.set(cacheKey, result); + return result; + } + + // Try to resolve the symbol via the type checker + try { + const namedImport = importDecl.getNamedImports().find(ni => { + const alias = ni.getAliasNode(); + return alias ? alias.getText() === symbolName : ni.getName() === symbolName; + }); + + if (!namedImport) { + const defaultImport = importDecl.getDefaultImport(); + if (defaultImport && defaultImport.getText() === symbolName) { + const result = this._classifyFromNode(defaultImport, symbolName); + this.classificationCache.set(cacheKey, result); + return result; + } + const result = this._fallbackClassify(symbolName); + this.classificationCache.set(cacheKey, result); + return result; + } + + const result = this._classifyFromNode(namedImport, symbolName); + this.classificationCache.set(cacheKey, result); + return result; + } catch { + const result = this._fallbackClassify(symbolName); + this.classificationCache.set(cacheKey, result); + return result; + } + } + + /** + * Generate a description for an unknown symbol based on naming conventions. + * Used when .d.ts resolution failed and the symbol is classified as 'unknown'. + */ + public describeUnknownSymbol(symbolName: string): string { + if (/^use[A-Z]/.test(symbolName)) { + return 'Likely a React hook (use* naming convention)'; + } + + for (const suffix of TYPE_NAME_SUFFIXES) { + if (symbolName.endsWith(suffix) && symbolName.length > suffix.length) { + return `Likely a type/interface (*${suffix} naming convention)`; + } + } + + if (/^I[A-Z]/.test(symbolName) && symbolName.length > 2) { + return 'Likely an interface (I* naming convention)'; + } + + if (/^[A-Z]/.test(symbolName)) { + return 'PascalCase symbol — could be a component, constant, or type'; + } + + return 'Unresolved symbol — .d.ts not available'; + } + + // ---- Private helpers ---- + + /** + * Classify a symbol by resolving its declaration from an AST node. + */ + private _classifyFromNode(node: Node, symbolName: string): SymbolClassification { + try { + const symbol = node.getSymbol(); + if (!symbol) { + return this._fallbackClassify(symbolName); + } + + // Follow aliases to the actual declaration + const aliased = symbol.getAliasedSymbol() ?? symbol; + const declarations = aliased.getDeclarations(); + + if (declarations.length === 0) { + return this._fallbackClassify(symbolName); + } + + // Only classify from .d.ts declarations. + // If resolved declarations come from non-.d.ts files (e.g., .ts/.tsx source + // in symlinked node_modules), treat as unknown — we only trust type declarations. + const dtsDeclarations = declarations.filter(d => d.getSourceFile().getFilePath().endsWith('.d.ts')); + if (dtsDeclarations.length === 0) { + return this._fallbackClassify(symbolName); + } + + return this._classifyFromDeclarations(dtsDeclarations, symbolName); + } catch { + return this._fallbackClassify(symbolName); + } + } + + /** + * Classify a symbol from its resolved declarations. + */ + private _classifyFromDeclarations( + declarations: ReadonlyArray, + symbolName: string, + ): SymbolClassification { + for (const decl of declarations) { + // Pure type: interface or type alias + if (Node.isInterfaceDeclaration(decl) || Node.isTypeAliasDeclaration(decl)) { + return 'type'; + } + + // Function declaration: check return type + if (Node.isFunctionDeclaration(decl)) { + if (this._returnsJsx(decl.getReturnType().getText())) { + return 'component'; + } + continue; + } + + // Variable declaration (const MyComponent: FC = ...) or arrow function + if (Node.isVariableDeclaration(decl)) { + const typeText = decl.getType().getText(); + // Check for React.FC, React.ForwardRefExoticComponent, etc. + if (this._isReactComponentType(typeText)) { + return 'component'; + } + // Check if the initializer is an arrow function that returns JSX + const init = decl.getInitializer(); + if (init && (Node.isArrowFunction(init) || Node.isFunctionExpression(init))) { + if (this._returnsJsx(init.getReturnType().getText())) { + return 'component'; + } + } + continue; + } + + // Class declaration: check for render() method + if (Node.isClassDeclaration(decl)) { + const renderMethod = decl.getMethod('render'); + if (renderMethod && this._returnsJsx(renderMethod.getReturnType().getText())) { + return 'component'; + } + continue; + } + + // Enum declaration — treat as type + if (Node.isEnumDeclaration(decl)) { + return 'type'; + } + } + + return 'other'; + } + + /** + * Check if a return type text indicates JSX. + */ + private _returnsJsx(returnTypeText: string): boolean { + for (const jsxType of JSX_TYPE_NAMES) { + if (returnTypeText.includes(jsxType)) { + return true; + } + } + return false; + } + + /** + * Check if a type text indicates a React component type (FC, ForwardRefExoticComponent, etc.). + */ + private _isReactComponentType(typeText: string): boolean { + const componentTypePatterns = [ + 'React.FC', + 'React.FunctionComponent', + 'React.ForwardRefExoticComponent', + 'ForwardRefComponent', + 'FC<', + 'FunctionComponent<', + 'ForwardRefExoticComponent<', + ]; + return componentTypePatterns.some(p => typeText.includes(p)); + } + + /** + * Fallback when type resolution is unavailable — returns 'unknown'. + */ + private _fallbackClassify(_name: string): SymbolClassification { + return 'unknown'; + } + + private _ensureProject(): void { + if (!this.project) { + throw new Error('AstParser: call createProject() before using the parser'); + } + } + + private _getSourceFile(filePath: string): SourceFile | undefined { + this._ensureProject(); + return this.project!.getSourceFile(filePath); + } + + /** + * Build a map from imported identifier name to its module specifier. + */ + private _buildImportMap(sourceFile: SourceFile): Map { + const map = new Map(); + + for (const decl of sourceFile.getImportDeclarations()) { + const moduleSpec = decl.getModuleSpecifierValue(); + + const defaultImport = decl.getDefaultImport(); + if (defaultImport) { + const localName = defaultImport.getText(); + map.set(localName, { importedName: localName, moduleSpecifier: moduleSpec }); + } + + for (const named of decl.getNamedImports()) { + const alias = named.getAliasNode(); + map.set(alias ? alias.getText() : named.getName(), { + importedName: named.getName(), + moduleSpecifier: moduleSpec, + }); + } + } + + return map; + } + + /** + * Extract props from a JSX element as key-value pairs. + */ + private _extractJsxProps(element: JsxOpeningElement | JsxSelfClosingElement): Record { + const props: Record = {}; + + for (const attr of element.getAttributes()) { + if (attr.getKind() === SyntaxKind.JsxAttribute) { + const jsxAttr = attr.asKind(SyntaxKind.JsxAttribute)!; + const name = jsxAttr.getNameNode().getText(); + const initializer = jsxAttr.getInitializer(); + + if (!initializer) { + // Boolean shorthand: