Skip to content

Commit f55ffda

Browse files
committed
feat(reporters): add console and json reporters for analyze command
Add reporter system to analyze command with console and json output options Improve feature type detection with import map support
1 parent beb023f commit f55ffda

File tree

8 files changed

+234
-25
lines changed

8 files changed

+234
-25
lines changed

packages/cli/src/commands/analyze.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { log } from '@clack/prompts'
2-
import { analyzeProject } from '@vuetify/cli-shared'
2+
import { analyzeProject, ConsoleReporter, JsonReporter, type Reporter } from '@vuetify/cli-shared'
33
import { defineCommand } from 'citty'
44
import { resolve } from 'pathe'
55

@@ -14,19 +14,44 @@ export const analyze = defineCommand({
1414
description: 'Directory to scan',
1515
default: '.',
1616
},
17+
output: {
18+
type: 'string',
19+
alias: 'o',
20+
description: 'Output file path (only for json reporter)',
21+
},
22+
reporter: {
23+
type: 'string',
24+
alias: 'r',
25+
description: 'Reporter to use (console, json)',
26+
default: 'console',
27+
valueHint: 'console | json',
28+
},
1729
suppressWarnings: {
1830
type: 'boolean',
1931
description: 'Suppress warnings',
2032
default: false,
2133
},
2234
},
2335
run: async ({ args }) => {
24-
if (!args.suppressWarnings) {
36+
if (!args.suppressWarnings && args.reporter !== 'json') {
2537
log.warn('This command is experimental and may change in the future.')
2638
}
2739
const cwd = resolve(process.cwd(), args.dir)
2840
const features = await analyzeProject(cwd)
29-
console.log(JSON.stringify(features, null, 2))
41+
42+
let reporter: Reporter
43+
switch (args.reporter) {
44+
case 'json': {
45+
reporter = JsonReporter
46+
break
47+
}
48+
default: {
49+
reporter = ConsoleReporter
50+
break
51+
}
52+
}
53+
54+
await reporter.report({ features }, { output: args.output })
3055
},
3156
})
3257

packages/cli/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,10 @@ await tab(main).then(completion => {
4444
}
4545
})
4646

47-
await checkForUpdate(version)
47+
const hasReporter = process.argv.includes('--reporter') || process.argv.includes('-r')
48+
49+
if (!hasReporter) {
50+
await checkForUpdate(version)
51+
}
52+
4853
runMain(main)

packages/shared/src/functions/analyze.ts

Lines changed: 119 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,51 @@
1+
import type { AnalyzedFeature, FeatureType } from '../reporters/types'
12
import { existsSync } from 'node:fs'
23
import { readFile } from 'node:fs/promises'
34
import { createRequire } from 'node:module'
5+
import { dirname, join } from 'pathe'
6+
import { resolvePackageJSON } from 'pkg-types'
47
import { glob } from 'tinyglobby'
58
import { parse } from 'vue-eslint-parser'
69

710
const require = createRequire(import.meta.url)
811

12+
async function loadImportMap (cwd: string, targetPackage: string) {
13+
try {
14+
const pkgPath = await resolvePackageJSON(targetPackage, { url: cwd })
15+
const pkgRoot = dirname(pkgPath)
16+
const mapPath = join(pkgRoot, 'dist/json/importMap.json')
17+
if (existsSync(mapPath)) {
18+
const content = await readFile(mapPath, 'utf8')
19+
return JSON.parse(content)
20+
}
21+
} catch (error) {
22+
console.warn('Failed to load importMap.json', error)
23+
}
24+
return null
25+
}
26+
27+
function getFeatureType (name: string, isType = false, importMap?: any): FeatureType {
28+
if (isType) {
29+
return 'type'
30+
}
31+
if (importMap?.components?.[name]) {
32+
return 'component'
33+
}
34+
if (name.startsWith('use') && name.length > 3 && name.at(3)?.match(/[A-Z]/)) {
35+
return 'composable'
36+
}
37+
if (/^create.*Plugin$/.test(name)) {
38+
return 'plugin'
39+
}
40+
if (/^[A-Z][A-Z0-9_]*$/.test(name)) {
41+
return 'constant'
42+
}
43+
if (/^[A-Z]/.test(name)) {
44+
return 'component'
45+
}
46+
return 'util'
47+
}
48+
949
function walk (node: any, callback: (node: any, parent: any) => void, parent?: any) {
1050
if (!node || typeof node !== 'object') {
1151
return
@@ -35,20 +75,38 @@ export function analyzeCode (code: string, targetPackage = '@vuetify/v0') {
3575
parser: require.resolve('@typescript-eslint/parser'),
3676
})
3777

38-
const found = new Set<string>()
39-
const importedFromVuetify = new Set<string>()
78+
const found = new Map<string, { isType: boolean }>()
4079

4180
if (ast.body) {
81+
// eslint-disable-next-line complexity
4282
walk(ast, (node, parent) => {
4383
// Static imports: import { X } from 'pkg'
4484
if (node.type === 'ImportDeclaration' && typeof node.source.value === 'string' && (node.source.value === targetPackage || node.source.value.startsWith(`${targetPackage}/`))) {
85+
const isDeclType = node.importKind === 'type'
4586
for (const spec of node.specifiers) {
87+
const isSpecType = spec.importKind === 'type'
88+
const isType = isDeclType || isSpecType
89+
4690
if (spec.type === 'ImportSpecifier' && 'name' in spec.imported) {
47-
found.add(spec.imported.name)
48-
importedFromVuetify.add(spec.local.name)
91+
const name = spec.imported.name
92+
const current = found.get(name)
93+
if (current) {
94+
if (!isType) {
95+
current.isType = false
96+
}
97+
} else {
98+
found.set(name, { isType })
99+
}
49100
} else if (spec.type === 'ImportDefaultSpecifier') {
50-
found.add('default')
51-
importedFromVuetify.add(spec.local.name)
101+
const name = 'default'
102+
const current = found.get(name)
103+
if (current) {
104+
if (!isType) {
105+
current.isType = false
106+
}
107+
} else {
108+
found.set(name, { isType })
109+
}
52110
}
53111
}
54112
}
@@ -59,10 +117,25 @@ export function analyzeCode (code: string, targetPackage = '@vuetify/v0') {
59117
&& parent?.type === 'MemberExpression' && parent.object === node) {
60118
if (parent.property.type === 'Identifier' && !parent.computed) {
61119
// .Prop
62-
found.add(parent.property.name)
120+
if (parent.property.name === 'then') {
121+
return
122+
}
123+
const name = parent.property.name
124+
const current = found.get(name)
125+
if (current) {
126+
current.isType = false
127+
} else {
128+
found.set(name, { isType: false })
129+
}
63130
} else if (parent.property.type === 'Literal') {
64131
// ['Prop']
65-
found.add(parent.property.value)
132+
const name = parent.property.value
133+
const current = found.get(name)
134+
if (current) {
135+
current.isType = false
136+
} else {
137+
found.set(name, { isType: false })
138+
}
66139
}
67140
}
68141
// Case 2: (await import('pkg')).Prop
@@ -81,42 +154,67 @@ export function analyzeCode (code: string, targetPackage = '@vuetify/v0') {
81154
const source = node.object.argument.source.value
82155
if (source === targetPackage) {
83156
if (node.property.type === 'Identifier' && !node.computed) {
84-
found.add(node.property.name)
157+
const name = node.property.name
158+
const current = found.get(name)
159+
if (current) {
160+
current.isType = false
161+
} else {
162+
found.set(name, { isType: false })
163+
}
85164
} else if (node.property.type === 'Literal') {
86-
found.add(node.property.value)
165+
const name = node.property.value
166+
const current = found.get(name)
167+
if (current) {
168+
current.isType = false
169+
} else {
170+
found.set(name, { isType: false })
171+
}
87172
}
88173
}
89174
}
90175
})
91176
}
92177

93-
return Array.from(found)
178+
return found
94179
}
95180

96-
export async function analyzeProject (cwd: string = process.cwd(), targetPackage = '@vuetify/v0') {
181+
export async function analyzeProject (cwd: string = process.cwd(), targetPackage = '@vuetify/v0'): Promise<AnalyzedFeature[]> {
97182
if (!existsSync(cwd)) {
98183
throw new Error(`Directory ${cwd} does not exist`)
99184
}
100185

101-
const files = await glob(['**/*.{vue,ts,js,tsx,jsx}'], {
102-
cwd,
103-
ignore: ['**/node_modules/**', '**/dist/**', '**/.git/**'],
104-
absolute: true,
105-
})
186+
const [files, importMap] = await Promise.all([
187+
glob(['**/*.{vue,ts,js,tsx,jsx}'], {
188+
cwd,
189+
ignore: ['**/node_modules/**', '**/dist/**', '**/.git/**'],
190+
absolute: true,
191+
}),
192+
loadImportMap(cwd, targetPackage),
193+
])
106194

107-
const features = new Set<string>()
195+
const features = new Map<string, { isType: boolean }>()
108196

109197
for (const file of files) {
110198
try {
111199
const code = await readFile(file, 'utf8')
112200
const fileFeatures = analyzeCode(code, targetPackage)
113-
for (const feature of fileFeatures) {
114-
features.add(feature)
201+
for (const [name, info] of fileFeatures) {
202+
const current = features.get(name)
203+
if (current) {
204+
if (!info.isType) {
205+
current.isType = false
206+
}
207+
} else {
208+
features.set(name, { isType: info.isType })
209+
}
115210
}
116211
} catch {
117212
// console.warn(`Failed to analyze ${file}:`, error)
118213
}
119214
}
120215

121-
return Array.from(features).toSorted()
216+
return Array.from(features.keys()).toSorted().map(name => ({
217+
name,
218+
type: getFeatureType(name, features.get(name)?.isType, importMap),
219+
}))
122220
}

packages/shared/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ export { projectArgs, type ProjectArgs } from './args'
22
export * from './commands'
33
export { registerProjectArgsCompletion } from './completion'
44
export * from './functions'
5+
export * from './reporters'
56
export * from './utils/banner'
67
export { templateBuilder } from './utils/template'
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { AnalyzeReport, Reporter } from './types'
2+
import { ansi256, bold, green, yellow } from 'kolorist'
3+
4+
const blue = ansi256(33)
5+
6+
export const ConsoleReporter: Reporter = {
7+
report: (data: AnalyzeReport) => {
8+
console.log()
9+
console.log(bold('Vuetify Analysis Report'))
10+
console.log(blue('======================='))
11+
console.log()
12+
13+
if (data.features.length === 0) {
14+
console.log(yellow('No Vuetify features detected.'))
15+
return
16+
}
17+
18+
console.log(`Detected ${green(data.features.length)} features:`)
19+
console.log()
20+
21+
const grouped = data.features.reduce((acc, feature) => {
22+
if (!acc[feature.type]) {
23+
acc[feature.type] = []
24+
}
25+
acc[feature.type].push(feature)
26+
return acc
27+
}, {} as Record<string, typeof data.features>)
28+
29+
const categories = ['component', 'composable', 'constant', 'plugin', 'util', 'type'] as const
30+
31+
for (const category of categories) {
32+
const features = grouped[category]
33+
if (features && features.length > 0) {
34+
console.log(bold(category.charAt(0).toUpperCase() + category.slice(1) + 's'))
35+
for (const feature of features) {
36+
console.log(` ${blue('•')} ${feature.name}`)
37+
}
38+
console.log()
39+
}
40+
}
41+
},
42+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './console'
2+
export * from './json'
3+
export * from './types'
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { AnalyzeReport, Reporter, ReporterOptions } from './types'
2+
import { writeFile } from 'node:fs/promises'
3+
import { resolve } from 'pathe'
4+
5+
export const JsonReporter: Reporter = {
6+
report: async (data: AnalyzeReport, options?: ReporterOptions) => {
7+
const output = JSON.stringify(data.features, null, 2)
8+
9+
if (options?.output) {
10+
const path = resolve(process.cwd(), options.output)
11+
await writeFile(path, output, 'utf8')
12+
console.log(`Report written to ${path}`)
13+
} else {
14+
console.log(output)
15+
}
16+
},
17+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export type FeatureType = 'component' | 'composable' | 'constant' | 'plugin' | 'util' | 'type'
2+
3+
export interface AnalyzedFeature {
4+
name: string
5+
type: FeatureType
6+
}
7+
8+
export interface AnalyzeReport {
9+
features: AnalyzedFeature[]
10+
}
11+
12+
export interface ReporterOptions {
13+
output?: string
14+
}
15+
16+
export interface Reporter {
17+
report: (data: AnalyzeReport, options?: ReporterOptions) => Promise<void> | void
18+
}

0 commit comments

Comments
 (0)