@avelonjs/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,415 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { readdir, readFile } from 'node:fs/promises'
3
+ import { dirname, extname, isAbsolute, join, relative } from 'node:path'
4
+ import * as ts from 'typescript'
5
+
6
+ import { writeln, type ReeveIO } from './io'
7
+
8
+ /** One docs:check failure. */
9
+ export interface DocsFinding {
10
+ /** Machine-readable kind. */
11
+ kind: 'missing-section' | 'unparsed-code-block' | 'missing-export' | 'missing-readme'
12
+ /** Human-readable explanation. */
13
+ message: string
14
+ }
15
+
16
+ /** Result of linting one package README. */
17
+ export interface DocsCheckResult {
18
+ /** Package directory that was checked. */
19
+ root: string
20
+ /** True when there are no findings. */
21
+ ok: boolean
22
+ /** Failures, empty when `ok`. */
23
+ findings: readonly DocsFinding[]
24
+ }
25
+
26
+ const REQUIRED_SECTIONS = ['Installation', 'Basic Usage', 'Method Reference', 'Testing'] as const
27
+
28
+ const PARSEABLE_LANGUAGES = new Set([
29
+ 'ts',
30
+ 'tsx',
31
+ 'typescript',
32
+ 'mts',
33
+ 'cts',
34
+ 'js',
35
+ 'jsx',
36
+ 'javascript',
37
+ 'mjs',
38
+ 'cjs',
39
+ ])
40
+
41
+ interface WaiverSet {
42
+ sections: Set<string>
43
+ exports: Set<string>
44
+ }
45
+
46
+ /** Lints one package README against house-style rules. */
47
+ export async function checkPackageDocs(root: string): Promise<DocsCheckResult> {
48
+ const findings: DocsFinding[] = []
49
+ const readmePath = join(root, 'README.md')
50
+ let readme: string
51
+ try {
52
+ readme = await readFile(readmePath, 'utf8')
53
+ } catch {
54
+ return {
55
+ root,
56
+ ok: false,
57
+ findings: [
58
+ {
59
+ kind: 'missing-readme',
60
+ message: `README.md is missing. A package without docs is incomplete.`,
61
+ },
62
+ ],
63
+ }
64
+ }
65
+
66
+ const waivers = parseWaivers(readme)
67
+ const headings = markdownHeadings(readme)
68
+ for (const section of REQUIRED_SECTIONS) {
69
+ if (headings.has(section) || waivers.sections.has(section.toLowerCase())) continue
70
+ findings.push({
71
+ kind: 'missing-section',
72
+ message: `Required section '${section}' is missing. Add the heading or an explicit waiver.`,
73
+ })
74
+ }
75
+
76
+ if (!hasIntroduction(readme)) {
77
+ findings.push({
78
+ kind: 'missing-section',
79
+ message:
80
+ 'Introduction is missing. Put what the package does and when to reach for it under the title, in under five sentences.',
81
+ })
82
+ }
83
+
84
+ for (const block of markdownCodeBlocks(readme)) {
85
+ const finding = parseCodeBlock(block.language, block.code, block.index)
86
+ if (finding !== undefined) findings.push(finding)
87
+ }
88
+
89
+ const documented = documentedExports(readme)
90
+ const exported = await collectPackageExports(root)
91
+ for (const name of exported) {
92
+ if (isExportDocumented(name, documented) || waivers.exports.has(name)) continue
93
+ findings.push({
94
+ kind: 'missing-export',
95
+ message: `Public export '${name}' is missing from the Method Reference table.`,
96
+ })
97
+ }
98
+
99
+ return { root, ok: findings.length === 0, findings }
100
+ }
101
+
102
+ /** Runs `docs:check` for one directory or every workspace package. */
103
+ export async function runDocsCheck(argv: readonly string[], io: ReeveIO): Promise<number> {
104
+ const workspace = argv.includes('--workspace')
105
+ const targets = argv.filter((arg) => !arg.startsWith('-'))
106
+ const roots = workspace
107
+ ? await workspacePackages(io.cwd)
108
+ : targets.length > 0
109
+ ? targets.map((target) => (isAbsolute(target) ? target : join(io.cwd, target)))
110
+ : await defaultRoots(io.cwd)
111
+
112
+ if (roots.length === 0) {
113
+ writeln(io, 'No packages to check.')
114
+ return 1
115
+ }
116
+
117
+ let failed = 0
118
+ for (const root of roots) {
119
+ const result = await checkPackageDocs(root)
120
+ const label = relative(io.cwd, root) || root
121
+ if (result.ok) {
122
+ writeln(io, `${label}: docs:check clean`)
123
+ continue
124
+ }
125
+ failed += 1
126
+ writeln(io, `${label}: docs:check failed`)
127
+ for (const finding of result.findings) writeln(io, ` ${finding.kind}: ${finding.message}`)
128
+ }
129
+ return failed === 0 ? 0 : 1
130
+ }
131
+
132
+ async function defaultRoots(cwd: string): Promise<string[]> {
133
+ if (await isPackageRoot(cwd)) return [cwd]
134
+ const nested = await workspacePackages(cwd)
135
+ return nested.length > 0 ? nested : [cwd]
136
+ }
137
+
138
+ async function workspacePackages(cwd: string): Promise<string[]> {
139
+ const dir = join(cwd, 'packages')
140
+ try {
141
+ const entries = await readdir(dir, { withFileTypes: true })
142
+ const roots: string[] = []
143
+ for (const entry of entries) {
144
+ if (!entry.isDirectory()) continue
145
+ const root = join(dir, entry.name)
146
+ if (await isPackageRoot(root)) roots.push(root)
147
+ }
148
+ return roots.sort()
149
+ } catch {
150
+ return []
151
+ }
152
+ }
153
+
154
+ async function isPackageRoot(root: string): Promise<boolean> {
155
+ try {
156
+ const raw = await readFile(join(root, 'package.json'), 'utf8')
157
+ const parsed: unknown = JSON.parse(raw)
158
+ return typeof parsed === 'object' && parsed !== null
159
+ } catch {
160
+ return false
161
+ }
162
+ }
163
+
164
+ function parseWaivers(readme: string): WaiverSet {
165
+ const sections = new Set<string>()
166
+ const exports = new Set<string>()
167
+ const pattern = /<!--\s*docs:check-waiver:\s*(section|export)\s+([^>]+?)\s*-->/gi
168
+ for (const match of readme.matchAll(pattern)) {
169
+ const kind = match[1]?.toLowerCase()
170
+ const name = match[2]?.trim()
171
+ if (kind === undefined || name === undefined) continue
172
+ if (kind === 'section') sections.add(name.toLowerCase())
173
+ else exports.add(name)
174
+ }
175
+ return { sections, exports }
176
+ }
177
+
178
+ function markdownHeadings(readme: string): Set<string> {
179
+ const headings = new Set<string>()
180
+ for (const line of readme.split(/\r?\n/)) {
181
+ const match = /^##\s+(.+?)\s*$/.exec(line)
182
+ const title = match?.[1]?.trim()
183
+ if (title !== undefined) headings.add(title)
184
+ }
185
+ return headings
186
+ }
187
+
188
+ function hasIntroduction(readme: string): boolean {
189
+ const withoutFence = readme.replace(/^```[\s\S]*?^```/gm, '')
190
+ const match = /^#\s+.+\n+([\s\S]*?)(?:\n##\s|\s*$)/.exec(withoutFence)
191
+ const intro = match?.[1]?.trim() ?? ''
192
+ return intro.length > 0 && !intro.startsWith('#')
193
+ }
194
+
195
+ interface CodeBlock {
196
+ language: string
197
+ code: string
198
+ index: number
199
+ }
200
+
201
+ function markdownCodeBlocks(readme: string): CodeBlock[] {
202
+ const blocks: CodeBlock[] = []
203
+ const pattern = /^```([^\n`]*)\n([\s\S]*?)^```/gm
204
+ let index = 0
205
+ for (const match of readme.matchAll(pattern)) {
206
+ index += 1
207
+ blocks.push({
208
+ language: (match[1] ?? '').trim().toLowerCase(),
209
+ code: match[2] ?? '',
210
+ index,
211
+ })
212
+ }
213
+ return blocks
214
+ }
215
+
216
+ function parseCodeBlock(language: string, code: string, index: number): DocsFinding | undefined {
217
+ if (language === 'json') {
218
+ try {
219
+ JSON.parse(code)
220
+ return undefined
221
+ } catch (error) {
222
+ return {
223
+ kind: 'unparsed-code-block',
224
+ message: `Code block ${String(index)} (json) does not parse: ${errorMessage(error)}`,
225
+ }
226
+ }
227
+ }
228
+ if (language.length === 0 || !PARSEABLE_LANGUAGES.has(language)) return undefined
229
+ const fileName = language === 'tsx' || language === 'jsx' ? 'example.tsx' : 'example.ts'
230
+ const transpile = ts.transpileModule(code, {
231
+ compilerOptions: {
232
+ module: ts.ModuleKind.ESNext,
233
+ target: ts.ScriptTarget.ES2022,
234
+ jsx: ts.JsxEmit.ReactJSX,
235
+ },
236
+ reportDiagnostics: true,
237
+ fileName,
238
+ })
239
+ const errors = (transpile.diagnostics ?? []).filter(
240
+ (diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error,
241
+ )
242
+ if (errors.length === 0) return undefined
243
+ return {
244
+ kind: 'unparsed-code-block',
245
+ message: `Code block ${String(index)} (${language}) does not parse: ${formatDiagnostic(errors[0])}`,
246
+ }
247
+ }
248
+
249
+ function formatDiagnostic(diagnostic: ts.Diagnostic | undefined): string {
250
+ if (diagnostic === undefined) return 'syntax error'
251
+ return ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')
252
+ }
253
+
254
+ function documentedExports(readme: string): Set<string> {
255
+ const names = new Set<string>()
256
+ const start = readme.search(/^##\s+Method Reference\s*$/m)
257
+ if (start === -1) return names
258
+ const rest = readme.slice(start)
259
+ const endMatch = rest.slice(1).search(/^##\s+/m)
260
+ const table = endMatch === -1 ? rest : rest.slice(0, endMatch + 1)
261
+ for (const line of table.split(/\r?\n/)) {
262
+ if (!line.trim().startsWith('|')) continue
263
+ const cells = line
264
+ .trim()
265
+ .split('|')
266
+ .slice(1, -1)
267
+ .map((cell) => cell.trim())
268
+ const first = cells[0]
269
+ if (first === undefined || first.length === 0) continue
270
+ if (/^[-:]+$/.test(first.replace(/\s/g, ''))) continue
271
+ if (/^method/i.test(first)) continue
272
+ const token = first.replace(/^`+|`+$/g, '').trim()
273
+ if (token.length === 0) continue
274
+ names.add(token)
275
+ }
276
+ return names
277
+ }
278
+
279
+ function isExportDocumented(name: string, documented: Set<string>): boolean {
280
+ if (documented.has(name)) return true
281
+ for (const entry of documented) {
282
+ if (entry === name) return true
283
+ if (entry.startsWith(`${name}.`)) return true
284
+ }
285
+ return false
286
+ }
287
+
288
+ async function collectPackageExports(root: string): Promise<string[]> {
289
+ const entry = await packageEntry(root)
290
+ if (entry === undefined) return []
291
+ const names = new Set<string>()
292
+ await collectFromFile(entry, root, names, new Set())
293
+ return [...names].sort()
294
+ }
295
+
296
+ async function packageEntry(root: string): Promise<string | undefined> {
297
+ try {
298
+ const raw = await readFile(join(root, 'package.json'), 'utf8')
299
+ const parsed: unknown = JSON.parse(raw)
300
+ if (typeof parsed !== 'object' || parsed === null) return undefined
301
+ const exportsField = Reflect.get(parsed, 'exports')
302
+ const main = Reflect.get(parsed, 'main')
303
+ const fromExports = resolveExportField(exportsField)
304
+ const relative = fromExports ?? (typeof main === 'string' ? main : 'src/index.ts')
305
+ return join(root, relative)
306
+ } catch {
307
+ return undefined
308
+ }
309
+ }
310
+
311
+ function resolveExportField(exportsField: unknown): string | undefined {
312
+ if (typeof exportsField === 'string') return exportsField
313
+ if (typeof exportsField !== 'object' || exportsField === null) return undefined
314
+ const dot = Reflect.get(exportsField, '.')
315
+ if (typeof dot === 'string') return dot
316
+ if (typeof dot === 'object' && dot !== null) {
317
+ const importPath = Reflect.get(dot, 'import')
318
+ const defaultPath = Reflect.get(dot, 'default')
319
+ if (typeof importPath === 'string') return importPath
320
+ if (typeof defaultPath === 'string') return defaultPath
321
+ }
322
+ return undefined
323
+ }
324
+
325
+ async function collectFromFile(
326
+ file: string,
327
+ root: string,
328
+ names: Set<string>,
329
+ seen: Set<string>,
330
+ ): Promise<void> {
331
+ if (seen.has(file)) return
332
+ seen.add(file)
333
+ let sourceText: string
334
+ try {
335
+ sourceText = await readFile(file, 'utf8')
336
+ } catch {
337
+ return
338
+ }
339
+ const kind = extname(file) === '.tsx' ? ts.ScriptKind.TSX : ts.ScriptKind.TS
340
+ const source = ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, true, kind)
341
+ for (const statement of source.statements) {
342
+ if (ts.isExportDeclaration(statement)) {
343
+ const moduleSpecifier = statement.moduleSpecifier
344
+ const resolved =
345
+ moduleSpecifier !== undefined && ts.isStringLiteral(moduleSpecifier)
346
+ ? resolveRelative(file, root, moduleSpecifier.text)
347
+ : undefined
348
+ if (statement.exportClause === undefined) {
349
+ if (resolved !== undefined) await collectFromFile(resolved, root, names, seen)
350
+ continue
351
+ }
352
+ if (ts.isNamespaceExport(statement.exportClause)) {
353
+ names.add(statement.exportClause.name.text)
354
+ continue
355
+ }
356
+ for (const element of statement.exportClause.elements) {
357
+ names.add(element.name.text)
358
+ }
359
+ continue
360
+ }
361
+ if (ts.isExportAssignment(statement)) {
362
+ names.add('default')
363
+ continue
364
+ }
365
+ const mods = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined
366
+ const exported =
367
+ mods?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) === true
368
+ if (!exported) continue
369
+ if (ts.isFunctionDeclaration(statement) && statement.name !== undefined) {
370
+ names.add(statement.name.text)
371
+ continue
372
+ }
373
+ if (ts.isClassDeclaration(statement) && statement.name !== undefined) {
374
+ names.add(statement.name.text)
375
+ continue
376
+ }
377
+ if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) {
378
+ names.add(statement.name.text)
379
+ continue
380
+ }
381
+ if (ts.isEnumDeclaration(statement)) {
382
+ names.add(statement.name.text)
383
+ continue
384
+ }
385
+ if (ts.isVariableStatement(statement)) {
386
+ for (const declaration of statement.declarationList.declarations) {
387
+ if (ts.isIdentifier(declaration.name)) names.add(declaration.name.text)
388
+ }
389
+ }
390
+ }
391
+ }
392
+
393
+ function resolveRelative(fromFile: string, root: string, specifier: string): string | undefined {
394
+ if (!specifier.startsWith('.')) return undefined
395
+ const base = join(dirname(fromFile), specifier)
396
+ const candidates = [
397
+ base,
398
+ `${base}.ts`,
399
+ `${base}.tsx`,
400
+ `${base}.mts`,
401
+ `${base}.cts`,
402
+ join(base, 'index.ts'),
403
+ join(base, 'index.tsx'),
404
+ ]
405
+ for (const candidate of candidates) {
406
+ const rel = relative(root, candidate)
407
+ if (rel.startsWith('..')) continue
408
+ if (existsSync(candidate)) return candidate
409
+ }
410
+ return undefined
411
+ }
412
+
413
+ function errorMessage(error: unknown): string {
414
+ return error instanceof Error ? error.message : String(error)
415
+ }