@conduction/nextcloud-vue 2.2.0-vue3.13 → 2.2.0-vue3.15

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,724 @@
1
+ /* eslint-disable no-console, n/no-process-exit */
2
+ /**
3
+ * Verifies that every public export of @conduction/nextcloud-vue has
4
+ * corresponding documentation under docs/.
5
+ *
6
+ * The source of truth for "public / user-reachable" is src/index.js.
7
+ * Every named export there is classified and checked against docs/:
8
+ *
9
+ * - Components (Cn*) → docs/components/<kebab>.md
10
+ * - Composables (use*) → docs/utilities/composables/<kebab>.md
11
+ * - Store factories → docs/store/<stem>.md
12
+ * useObjectStore, createObjectStore → object-store.md
13
+ * createCrudStore → crud-store.md
14
+ * createSubResourcePlugin → sub-resource-plugin.md
15
+ * - Store plugins (*Plugin) → docs/store/plugins/<kebab>.md
16
+ * - Store constants/helpers → mentioned by name in an existing docs/store/*.md
17
+ * SEARCH_TYPE, emptyPaginated, getRegisterApiUrl, getSchemaApiUrl
18
+ * - Utilities (everything else from utils) → docs/utilities/<kebab>.md
19
+ *
20
+ * Exempt exports are allow-listed in EXEMPT (e.g. registerIcons).
21
+ *
22
+ * Phase 1 — existence: exits 1 when any export lacks a doc file.
23
+ * Phase 2 — accuracy: for every Component export, verifies that each prop
24
+ * name and each static named-slot from the SFC appears somewhere in the
25
+ * component's doc file. Exits 1 when any are missing.
26
+ *
27
+ * All categories are reported in a single run so CI surfaces every gap at once.
28
+ */
29
+
30
+ const fs = require('fs')
31
+ const path = require('path')
32
+
33
+ const ROOT = path.resolve(__dirname, '..')
34
+ const INDEX_FILE = path.join(ROOT, 'src', 'index.js')
35
+ const DOCS_DIR = path.join(ROOT, 'docs')
36
+
37
+ /**
38
+ * Exports that don't need a dedicated doc page. classify() short-circuits
39
+ * to `category: 'Exempt'` for anything listed here, and the main loop
40
+ * skips exempt items entirely (not counted toward any category's totals).
41
+ *
42
+ * Use sparingly — reserved for lifecycle/bootstrap helpers that are
43
+ * covered by another doc (e.g. `registerIcons` is described in
44
+ * docs/getting-started.md as part of the install flow, not as a
45
+ * standalone API).
46
+ */
47
+ const EXEMPT = new Set([
48
+ 'registerIcons',
49
+ 'registerTranslations',
50
+ // Override (shadow) of @nextcloud/vue's own NcSelectTags — fixes the
51
+ // systemtags fetch and `:options` handling without changing the public
52
+ // API. The component contract is upstream's; its docs live in
53
+ // @nextcloud/vue, so there is no standalone Conduction page to require.
54
+ 'NcSelectTags',
55
+ ])
56
+
57
+ /**
58
+ * Store factory/helper identifiers → their expected doc filename stem
59
+ * under docs/store/. Needed because the default kebab-case rule would
60
+ * produce e.g. 'use-object-store', but the established convention strips
61
+ * the leading verb (`use`/`create`) so the docs read as 'object-store',
62
+ * 'crud-store', etc.
63
+ *
64
+ * Multiple identifiers may map to the same stem when they're part of
65
+ * one API surface — `useObjectStore` and `createObjectStore` both point
66
+ * at docs/store/object-store.md because a single page documents the
67
+ * singleton hook and its factory together.
68
+ */
69
+ const STORE_FACTORY_STEMS = {
70
+ useObjectStore: 'object-store',
71
+ createObjectStore: 'object-store',
72
+ createCrudStore: 'crud-store',
73
+ createSubResourcePlugin: 'sub-resource-plugin',
74
+ }
75
+
76
+ /**
77
+ * Exports that don't warrant their own .md file but MUST be mentioned
78
+ * by name in an existing doc. Keys are the export names; values are
79
+ * paths (relative to docs/store/) of the file that's expected to
80
+ * reference them.
81
+ *
82
+ * Used for constants and small helpers that belong with a larger API:
83
+ * `SEARCH_TYPE` only makes sense alongside `searchPlugin`, so it's
84
+ * checked inside plugins/search.md rather than getting a standalone
85
+ * 'search-type.md'. Coverage passes when the file exists and a plain
86
+ * substring match for the symbol name is found.
87
+ */
88
+ const STORE_MENTION_ONLY = {
89
+ SEARCH_TYPE: 'plugins/search.md',
90
+ emptyPaginated: 'sub-resource-plugin.md',
91
+ getRegisterApiUrl: 'plugins.md',
92
+ getSchemaApiUrl: 'plugins.md',
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // JS source helpers (used by Phase 2 accuracy check — non-component exports)
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /**
100
+ * @param names that should be skipped during the accuracy check because they
101
+ * are either generic containers ('options', 'params') or too short to carry
102
+ * meaningful signal ('e', 'err'). The check would produce too many false
103
+ * positives if it required every doc to spell out these placeholder names.
104
+ */
105
+ const SKIP_PARAMS = new Set(['options', 'params', 'e', 'err', 'error', 'cb', 'fn', 'response'])
106
+
107
+ /**
108
+ * Find the JS source file that contains the definition of a given export.
109
+ * Returns null when the file cannot be determined (e.g. store constants that
110
+ * live in the same file as a plugin — those are covered by mention-only checks
111
+ * in Phase 1 and don't need an accuracy check).
112
+ * @param {string} exportName identifier from src/index.js
113
+ * @param {string} category classification returned by classify()
114
+ * @return {string|null} absolute path to the source file, or null
115
+ */
116
+ function findSourceFile(exportName, category) {
117
+ if (category === 'Composables') {
118
+ return path.join(ROOT, 'src', 'composables', `${exportName}.js`)
119
+ }
120
+ if (category === 'Store plugins') {
121
+ // e.g. 'auditTrailsPlugin' → 'auditTrails.js'
122
+ const stem = exportName.slice(0, -'Plugin'.length)
123
+ return path.join(ROOT, 'src', 'store', 'plugins', `${stem}.js`)
124
+ }
125
+ if (category === 'Store factories') {
126
+ const map = {
127
+ useObjectStore: path.join(ROOT, 'src', 'store', 'index.js'),
128
+ createObjectStore: path.join(ROOT, 'src', 'store', 'index.js'),
129
+ createCrudStore: path.join(ROOT, 'src', 'store', 'createCrudStore.js'),
130
+ createSubResourcePlugin: path.join(ROOT, 'src', 'store', 'createSubResourcePlugin.js'),
131
+ }
132
+ return map[exportName] || null
133
+ }
134
+ if (category === 'Utilities') {
135
+ // Search every utils/*.js file (excluding the barrel) for this export
136
+ const utilDir = path.join(ROOT, 'src', 'utils')
137
+ for (const file of fs.readdirSync(utilDir)) {
138
+ if (!file.endsWith('.js') || file === 'index.js') continue
139
+ const filePath = path.join(utilDir, file)
140
+ const content = fs.readFileSync(filePath, 'utf8')
141
+ const funcRe = new RegExp(`export\\s+(?:async\\s+)?function\\s+${exportName}\\b`)
142
+ const constRe = new RegExp(`export\\s+const\\s+${exportName}\\b`)
143
+ if (funcRe.test(content) || constRe.test(content)) return filePath
144
+ }
145
+ return null
146
+ }
147
+ return null
148
+ }
149
+
150
+ /**
151
+ * Extract @param names from the JSDoc block immediately preceding the named
152
+ * export declaration in a JS file. Only looks at the JSDoc for that specific
153
+ * function so params from sibling exports don't pollute results.
154
+ * @param {string} filePath absolute path to the .js source file
155
+ * @param {string} exportName identifier to look up (function or const name)
156
+ * @return {string[]} @param identifiers (may include 'options.subKey' dotted forms)
157
+ */
158
+ function extractFunctionParams(filePath, exportName) {
159
+ if (!filePath || !fs.existsSync(filePath)) return []
160
+ const source = fs.readFileSync(filePath, 'utf8')
161
+
162
+ // Locate the export declaration for this specific function
163
+ const funcRe = new RegExp(
164
+ `export\\s+(?:async\\s+)?function\\s+${exportName}\\b|export\\s+const\\s+${exportName}\\s*=`,
165
+ )
166
+ const funcMatch = funcRe.exec(source)
167
+ if (!funcMatch) return []
168
+
169
+ // Find the last /** ... */ JSDoc block that sits before this declaration
170
+ const before = source.slice(0, funcMatch.index)
171
+ const jsdocRe = /\/\*\*[\s\S]*?\*\//g
172
+ let lastJsdoc = null
173
+ let jsdocMatch
174
+ while ((jsdocMatch = jsdocRe.exec(before)) !== null) {
175
+ lastJsdoc = jsdocMatch[0]
176
+ }
177
+ if (!lastJsdoc) return []
178
+
179
+ // Pull out every @param identifier (handles both plain and [optional] forms)
180
+ const paramRe = /@param\s+\{[^}]+\}\s+\[?([a-zA-Z][a-zA-Z0-9._]*)\]?/g
181
+ const params = new Set()
182
+ let m
183
+ while ((m = paramRe.exec(lastJsdoc)) !== null) {
184
+ params.add(m[1])
185
+ }
186
+ return [...params]
187
+ }
188
+
189
+ /**
190
+ * Accuracy check for a non-component JS export: verifies that every
191
+ * meaningful @param name from the JSDoc appears in the export's doc file
192
+ * (by exact match or, for `options.subKey` dotted paths, by the leaf name).
193
+ * @param {string} exportName identifier from src/index.js
194
+ * @param {string|null} srcPath absolute path to the source file
195
+ * @param {string} docPath absolute path to the expected .md doc file
196
+ * @return {string[]} plain-English issue strings (empty when all covered)
197
+ */
198
+ function checkJsAccuracy(exportName, srcPath, docPath) {
199
+ if (!srcPath || !fs.existsSync(srcPath) || !fs.existsSync(docPath)) return []
200
+
201
+ const params = extractFunctionParams(srcPath, exportName)
202
+ if (params.length === 0) return []
203
+
204
+ const docContent = fs.readFileSync(docPath, 'utf8')
205
+ const issues = []
206
+
207
+ for (const param of params) {
208
+ const parts = param.split('.')
209
+ const leaf = parts[parts.length - 1]
210
+
211
+ // Skip generic placeholder names
212
+ if (SKIP_PARAMS.has(leaf) || SKIP_PARAMS.has(param)) continue
213
+ // Skip single-character params and dual-form implementation names
214
+ if (leaf.length <= 1 || param.endsWith('OrOptions') || param.endsWith('OrString')) continue
215
+
216
+ // Check: the exact param name OR (for dotted paths) just the leaf must appear in the doc
217
+ if (!docContent.includes(param) && !docContent.includes(leaf)) {
218
+ issues.push(`param \`${param}\` not mentioned in doc`)
219
+ }
220
+ }
221
+
222
+ return issues
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // SFC parsing helpers (used by Phase 2 accuracy check — Component exports)
227
+ // ---------------------------------------------------------------------------
228
+
229
+ /**
230
+ * Extract prop names from a Vue SFC's script-block `props:` definition.
231
+ * Handles both object form (`props: { name: { ... } }`) and array form
232
+ * (`props: ['name1', 'name2']`). Uses brace-depth tracking so prop option
233
+ * keys (type, default, required, validator) are not mistaken for prop names.
234
+ * @param {string} sfcPath absolute path to the .vue file
235
+ * @return {string[]} camelCase prop names, or empty array when none found
236
+ */
237
+ function extractSfcProps(sfcPath) {
238
+ if (!fs.existsSync(sfcPath)) return []
239
+ const source = fs.readFileSync(sfcPath, 'utf8')
240
+ // Locate the SFC <script> block by hand instead of a single regex so
241
+ // CodeQL (js/bad-tag-filter) can't construct adversarial close-tag
242
+ // variants like `</script\t bar>`. The open tag still uses a tight
243
+ // regex (no `[^>]` ambiguity beyond the `>` itself); the close tag is
244
+ // found by case-insensitive string search after the opener.
245
+ const openMatch = source.match(/<script\b[^>]*>/i)
246
+ if (!openMatch) return []
247
+ const openEnd = openMatch.index + openMatch[0].length
248
+ const closeIdx = source.toLowerCase().indexOf('</script', openEnd)
249
+ if (closeIdx === -1) return []
250
+ const script = source.slice(openEnd, closeIdx)
251
+
252
+ const propsIdx = script.search(/\bprops\s*:/)
253
+ if (propsIdx === -1) return []
254
+ const afterProps = script.slice(propsIdx)
255
+
256
+ // Array form: props: ['a', 'b']
257
+ const arrMatch = afterProps.match(/^props\s*:\s*\[([^\]]*)\]/)
258
+ if (arrMatch) {
259
+ const names = []
260
+ const re = /['"]([^'"]+)['"]/g
261
+ let m
262
+ while ((m = re.exec(arrMatch[1])) !== null) names.push(m[1])
263
+ return names
264
+ }
265
+
266
+ // Object form: walk characters tracking brace depth.
267
+ // Keys are captured at depth 1 (direct children of the props object):
268
+ // - Right before a `{` opens a sub-object (e.g. `title: {`)
269
+ // - At end of line for flat props (e.g. `name: String,`)
270
+ const braceIdx = afterProps.indexOf('{')
271
+ if (braceIdx === -1) return []
272
+
273
+ const propNames = []
274
+ let depth = 0
275
+ let lineText = ''
276
+
277
+ for (let i = braceIdx; i < afterProps.length; i++) {
278
+ const ch = afterProps[i]
279
+ if (ch === '{') {
280
+ // Capture key immediately before its options object opens
281
+ if (depth === 1) {
282
+ const key = lineText.match(/^\s+([a-zA-Z][a-zA-Z0-9]*)\s*:/)
283
+ if (key) propNames.push(key[1])
284
+ }
285
+ depth++
286
+ } else if (ch === '}') {
287
+ depth--
288
+ if (depth === 0) break
289
+ } else if (ch === '\n') {
290
+ // Capture flat prop (no sub-object) at end of its line
291
+ if (depth === 1) {
292
+ const key = lineText.match(/^\s+([a-zA-Z][a-zA-Z0-9]*)\s*:/)
293
+ if (key) propNames.push(key[1])
294
+ }
295
+ lineText = ''
296
+ continue
297
+ }
298
+ lineText += ch
299
+ }
300
+
301
+ return [...new Set(propNames)]
302
+ }
303
+
304
+ /**
305
+ * Extract names of all statically-named `<slot>` elements from a Vue SFC
306
+ * template. Dynamic `:name="..."` bindings are intentionally skipped since
307
+ * the slot name is only known at runtime and cannot be literally checked.
308
+ * @param {string} sfcPath absolute path to the .vue file
309
+ * @return {string[]} static slot names (the implicit default slot is excluded)
310
+ */
311
+ function extractSfcNamedSlots(sfcPath) {
312
+ if (!fs.existsSync(sfcPath)) return []
313
+ const source = fs.readFileSync(sfcPath, 'utf8')
314
+ const templateMatch = source.match(/<template\b[^>]*>([\s\S]*?)<\/template>/m)
315
+ if (!templateMatch) return []
316
+
317
+ const slots = new Set()
318
+ const slotRe = /<slot\b([^>]*?)(?:\s*\/?>)/g
319
+ let m
320
+ while ((m = slotRe.exec(templateMatch[1])) !== null) {
321
+ const attrs = m[1]
322
+ if (attrs.includes(':name=')) continue // skip dynamic slot names
323
+ const nameM = attrs.match(/\bname="([^"]+)"/)
324
+ if (nameM) slots.add(nameM[1])
325
+ }
326
+ return [...slots]
327
+ }
328
+
329
+ /**
330
+ * Accuracy check for one Component export: verifies that every prop name and
331
+ * every static named slot defined in the SFC is mentioned at least once in
332
+ * the component's doc file (by camelCase or kebab-case name).
333
+ * @param {string} componentName PascalCase name (e.g. 'CnWidgetWrapper')
334
+ * @param {string} docPath absolute path to the component's .md file
335
+ * @return {string[]} plain-English issue strings (empty when all covered)
336
+ */
337
+ function checkComponentDetail(componentName, docPath) {
338
+ const sfcPath = path.join(ROOT, 'src', 'components', componentName, `${componentName}.vue`)
339
+ if (!fs.existsSync(sfcPath) || !fs.existsSync(docPath)) return []
340
+
341
+ const docContent = fs.readFileSync(docPath, 'utf8')
342
+ const issues = []
343
+
344
+ for (const prop of extractSfcProps(sfcPath)) {
345
+ const kebab = toKebab(prop)
346
+ if (!docContent.includes(prop) && !docContent.includes(kebab)) {
347
+ issues.push(`prop \`${prop}\` (${kebab}) not mentioned in doc`)
348
+ }
349
+ }
350
+
351
+ for (const slot of extractSfcNamedSlots(sfcPath)) {
352
+ if (!docContent.includes(slot)) {
353
+ issues.push(`slot \`${slot}\` not mentioned in doc`)
354
+ }
355
+ }
356
+
357
+ return issues
358
+ }
359
+
360
+ // ---------------------------------------------------------------------------
361
+ // Shared utilities
362
+ // ---------------------------------------------------------------------------
363
+
364
+ /**
365
+ * Convert PascalCase, camelCase, or SCREAMING_SNAKE_CASE to kebab-case.
366
+ *
367
+ * SCREAMING_SNAKE_CASE detection — when the name contains an underscore we
368
+ * treat it as snake-cased and just lowercase + replace `_` with `-`. Without
369
+ * this branch, `SAFE_MARKDOWN_DOMPURIFY_CONFIG` would become
370
+ * `s-a-f-e_-m-a-r-k-d-o-w-n_...` because the per-uppercase-character regex
371
+ * would insert a dash before every capital. Constants like
372
+ * `SAFE_MARKDOWN_DOMPURIFY_CONFIG` and `ROADMAP_LABEL_BLOCKLIST` map cleanly
373
+ * to `safe-markdown-dompurify-config.md` / `roadmap-label-blocklist.md` this
374
+ * way.
375
+ *
376
+ * @param {string} name identifier to convert
377
+ * @return {string} kebab-cased form
378
+ */
379
+ function toKebab(name) {
380
+ if (name.includes('_')) {
381
+ return name.toLowerCase().replace(/_/g, '-')
382
+ }
383
+ return name.replace(/([A-Z])/g, (m, l, i) => (i === 0 ? '' : '-') + l.toLowerCase())
384
+ }
385
+
386
+ /**
387
+ * Collect all .md file stems under a directory. By default recurses into
388
+ * subdirectories; pass { recursive: false } to limit to the immediate dir.
389
+ * @param {string} dir absolute directory to walk
390
+ * @param {object} [options] options bag
391
+ * @param {boolean} [options.recursive] when false, only read the top level
392
+ * @return {Set<string>} set of markdown file stems (filename without extension)
393
+ */
394
+ function collectDocStems(dir, { recursive = true } = {}) {
395
+ const stems = new Set()
396
+ if (!fs.existsSync(dir)) {
397
+ return stems
398
+ }
399
+ function walk(current) {
400
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
401
+ const full = path.join(current, entry.name)
402
+ if (entry.isDirectory()) {
403
+ if (recursive) {
404
+ walk(full)
405
+ }
406
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
407
+ stems.add(path.basename(entry.name, '.md'))
408
+ }
409
+ }
410
+ }
411
+ walk(dir)
412
+ return stems
413
+ }
414
+
415
+ /**
416
+ * Extract all identifiers re-exported from src/index.js. Handles both
417
+ * export { a, b } from '...'
418
+ * export { a } from '...'
419
+ * forms (with optional trailing commas and newlines inside the braces).
420
+ * @param {string} filePath absolute path to the barrel file
421
+ * @return {string[]} deduped list of re-exported identifiers
422
+ */
423
+ function parsePublicExports(filePath) {
424
+ const source = fs.readFileSync(filePath, 'utf8')
425
+ const names = new Set()
426
+ const re = /export\s*\{([^}]+)\}\s*from\s*['"][^'"]+['"]/g
427
+ let match
428
+ while ((match = re.exec(source)) !== null) {
429
+ for (const raw of match[1].split(',')) {
430
+ // `foo as bar` → the *exported* name is `bar`; `foo` → `foo`.
431
+ const parts = raw.trim().split(/\s+as\s+/)
432
+ const name = (parts[1] || parts[0]).trim()
433
+ if (name) {
434
+ names.add(name)
435
+ }
436
+ }
437
+ }
438
+ return [...names]
439
+ }
440
+
441
+ /**
442
+ * Categorize a public export name and return how to satisfy its doc requirement.
443
+ * @param {string} name identifier from src/index.js
444
+ * @return {object} category descriptor used by isCovered()
445
+ */
446
+ function classify(name) {
447
+ if (EXEMPT.has(name)) {
448
+ return { category: 'Exempt' }
449
+ }
450
+ if (name in STORE_FACTORY_STEMS) {
451
+ const stem = STORE_FACTORY_STEMS[name]
452
+ return {
453
+ category: 'Store factories',
454
+ expectedStem: stem,
455
+ searchDir: path.join(DOCS_DIR, 'store'),
456
+ expectedPath: `docs/store/${stem}.md`,
457
+ }
458
+ }
459
+ if (name in STORE_MENTION_ONLY) {
460
+ const file = STORE_MENTION_ONLY[name]
461
+ return {
462
+ category: 'Store constants',
463
+ mentionFile: path.join(DOCS_DIR, 'store', file),
464
+ mentionSymbol: name,
465
+ expectedPath: `docs/store/${file} (must mention \`${name}\`)`,
466
+ }
467
+ }
468
+ // Built-in integration descriptors (`flowIntegration`, `deckIntegration`, …).
469
+ //
470
+ // These are data, not API: each is a frozen `{ id, label, icon, tab, widget,
471
+ // … }` object with no parameters to document, and there are 26 of them. A
472
+ // page each would be 26 near-identical stubs, and the requirement to write
473
+ // one is precisely what kept 24 of them unexported from the barrel for
474
+ // months (see scripts/check-integration-parity.js). One table in
475
+ // builtin-integrations.md is the useful documentation; this rule requires
476
+ // every descriptor to appear in it by name, exactly like STORE_MENTION_ONLY.
477
+ //
478
+ // `registerBuiltinIntegrations` / `registerLeafIntegrations` are functions,
479
+ // not descriptors, and keep their own pages — hence the `register` guard.
480
+ if (/Integration$/.test(name) && !name.startsWith('register')) {
481
+ return {
482
+ category: 'Integration descriptors',
483
+ mentionFile: path.join(DOCS_DIR, 'utilities', 'builtin-integrations.md'),
484
+ mentionSymbol: name,
485
+ expectedPath: `docs/utilities/builtin-integrations.md (must mention \`${name}\`)`,
486
+ }
487
+ }
488
+ if (/^Cn[A-Z]/.test(name)) {
489
+ const stem = toKebab(name)
490
+ return {
491
+ category: 'Components',
492
+ expectedStem: stem,
493
+ searchDir: path.join(DOCS_DIR, 'components'),
494
+ expectedPath: `docs/components/${stem}.md`,
495
+ }
496
+ }
497
+ if (name.endsWith('Plugin')) {
498
+ const stem = toKebab(name.slice(0, -'Plugin'.length))
499
+ return {
500
+ category: 'Store plugins',
501
+ expectedStem: stem,
502
+ searchDir: path.join(DOCS_DIR, 'store', 'plugins'),
503
+ expectedPath: `docs/store/plugins/${stem}.md`,
504
+ }
505
+ }
506
+ if (/^use[A-Z]/.test(name)) {
507
+ const stem = toKebab(name)
508
+ return {
509
+ category: 'Composables',
510
+ expectedStem: stem,
511
+ searchDir: path.join(DOCS_DIR, 'utilities', 'composables'),
512
+ expectedPath: `docs/utilities/composables/${stem}.md`,
513
+ }
514
+ }
515
+ const stem = toKebab(name)
516
+ return {
517
+ category: 'Utilities',
518
+ expectedStem: stem,
519
+ searchDir: path.join(DOCS_DIR, 'utilities'),
520
+ expectedPath: `docs/utilities/${stem}.md`,
521
+ }
522
+ }
523
+
524
+ /**
525
+ * Verify coverage for a single classified export. Returns true when covered.
526
+ * @param {object} info classify() result
527
+ * @return {boolean} whether the export's documentation requirement is satisfied
528
+ */
529
+ function isCovered(info) {
530
+ if (info.category === 'Exempt') {
531
+ return true
532
+ }
533
+ if (info.mentionFile) {
534
+ if (!fs.existsSync(info.mentionFile)) {
535
+ return false
536
+ }
537
+ const contents = fs.readFileSync(info.mentionFile, 'utf8')
538
+ return contents.includes(info.mentionSymbol)
539
+ }
540
+ const stems = collectDocStems(info.searchDir)
541
+ return stems.has(info.expectedStem)
542
+ }
543
+
544
+ // ---------------------------------------------------------------------------
545
+ // Phase 1: existence check
546
+ // ---------------------------------------------------------------------------
547
+
548
+ const exports_ = parsePublicExports(INDEX_FILE)
549
+
550
+ const categories = new Map()
551
+ const componentExports = [] // collected for Phase 2a (SFC prop/slot check)
552
+
553
+ // Categories that get a Phase 2b @param accuracy check (must have their own
554
+ // doc file — Store constants use mention-only and are skipped).
555
+ const JS_ACCURACY_CATEGORIES = new Set(['Composables', 'Utilities', 'Store factories', 'Store plugins'])
556
+ const jsExports = [] // { name, category, docPath } collected for Phase 2b
557
+
558
+ for (const name of exports_) {
559
+ const info = classify(name)
560
+ if (info.category === 'Exempt') {
561
+ continue
562
+ }
563
+ if (info.category === 'Components') {
564
+ componentExports.push(name)
565
+ }
566
+ // Collect JS exports that have a dedicated doc file for the @param check
567
+ if (JS_ACCURACY_CATEGORIES.has(info.category) && !info.mentionFile && info.searchDir && info.expectedStem) {
568
+ const docPath = path.join(info.searchDir, `${info.expectedStem}.md`)
569
+ jsExports.push({ name, category: info.category, docPath })
570
+ }
571
+ if (!categories.has(info.category)) {
572
+ categories.set(info.category, { checked: 0, missing: [] })
573
+ }
574
+ const bucket = categories.get(info.category)
575
+ bucket.checked += 1
576
+ if (!isCovered(info)) {
577
+ bucket.missing.push({ name, expectedPath: info.expectedPath })
578
+ }
579
+ }
580
+
581
+ const ORDER = ['Components', 'Composables', 'Store factories', 'Store plugins', 'Store constants', 'Utilities']
582
+ const ordered = [...categories.entries()].sort(([a], [b]) => ORDER.indexOf(a) - ORDER.indexOf(b))
583
+
584
+ let totalChecked = 0
585
+ let totalMissing = 0
586
+
587
+ console.log('Documentation coverage by category:\n')
588
+ for (const [category, { checked, missing }] of ordered) {
589
+ totalChecked += checked
590
+ totalMissing += missing.length
591
+ const covered = checked - missing.length
592
+ const mark = missing.length === 0 ? '✓' : '✗'
593
+ console.log(` ${mark} ${category}: ${covered}/${checked}`)
594
+ }
595
+
596
+ if (totalMissing > 0) {
597
+ console.error(`\n✗ ${totalMissing} export(s) are missing documentation:\n`)
598
+ for (const [category, { missing }] of ordered) {
599
+ if (missing.length === 0) {
600
+ continue
601
+ }
602
+ console.error(`${category}:`)
603
+ for (const { name, expectedPath } of missing) {
604
+ console.error(` - ${name} → ${expectedPath}`)
605
+ }
606
+ console.error('')
607
+ }
608
+ console.error('Create the missing files (or add the missing mention) so every public export is documented.')
609
+ process.exit(1)
610
+ }
611
+
612
+ console.log(`\n✓ All ${totalChecked} public exports are documented.`)
613
+
614
+ // ---------------------------------------------------------------------------
615
+ // Phase 2a: accuracy check — every SFC prop and named slot must appear in doc
616
+ // ---------------------------------------------------------------------------
617
+
618
+ console.log('\nChecking component doc accuracy (props and slots):\n')
619
+
620
+ const detailFailures = []
621
+
622
+ for (const name of componentExports) {
623
+ const kebab = toKebab(name)
624
+ const fileIssues = []
625
+
626
+ // docs/components/ reference doc (formal props/slots tables)
627
+ const docsPath = path.join(DOCS_DIR, 'components', `${kebab}.md`)
628
+ const docsIssues = checkComponentDetail(name, docsPath)
629
+ if (docsIssues.length > 0) {
630
+ fileIssues.push({ file: `docs/components/${kebab}.md`, issues: docsIssues })
631
+ }
632
+
633
+ // co-located styleguide example (takes priority in the styleguide renderer)
634
+ const colocatedPath = path.join(ROOT, 'src', 'components', name, `${name}.md`)
635
+ const colocatedIssues = checkComponentDetail(name, colocatedPath)
636
+ if (colocatedIssues.length > 0) {
637
+ fileIssues.push({ file: `src/components/${name}/${name}.md`, issues: colocatedIssues })
638
+ }
639
+
640
+ if (fileIssues.length > 0) {
641
+ detailFailures.push({ name, fileIssues })
642
+ }
643
+ }
644
+
645
+ const detailChecked = componentExports.length
646
+ const detailFailed = detailFailures.length
647
+
648
+ if (detailFailed === 0) {
649
+ console.log(` ✓ All ${detailChecked} component docs cover their props and slots.`)
650
+ } else {
651
+ console.log(` ✓ ${detailChecked - detailFailed}/${detailChecked} component docs cover their props and slots.`)
652
+ console.error(`\n✗ ${detailFailed} component doc(s) are missing prop or slot coverage:\n`)
653
+ for (const { name, fileIssues } of detailFailures) {
654
+ console.error(`${name}:`)
655
+ for (const { file, issues } of fileIssues) {
656
+ console.error(` ${file}:`)
657
+ for (const issue of issues) {
658
+ console.error(` - ${issue}`)
659
+ }
660
+ }
661
+ console.error('')
662
+ }
663
+ }
664
+
665
+ // ---------------------------------------------------------------------------
666
+ // Phase 2b: accuracy check — every JSDoc @param must appear in the doc
667
+ // ---------------------------------------------------------------------------
668
+
669
+ console.log('\nChecking JS export doc accuracy (JSDoc @param names):\n')
670
+
671
+ // Accumulate results per category so we can print a Phase-1-style summary
672
+ const jsDetailByCategory = new Map()
673
+ for (const cat of JS_ACCURACY_CATEGORIES) {
674
+ jsDetailByCategory.set(cat, { checked: 0, failures: [] })
675
+ }
676
+
677
+ for (const { name, category, docPath } of jsExports) {
678
+ const bucket = jsDetailByCategory.get(category)
679
+ bucket.checked += 1
680
+ const srcPath = findSourceFile(name, category)
681
+ const issues = checkJsAccuracy(name, srcPath, docPath)
682
+ if (issues.length > 0) {
683
+ bucket.failures.push({ name, issues, docPath })
684
+ }
685
+ }
686
+
687
+ const JS_ORDER = ['Composables', 'Store factories', 'Store plugins', 'Utilities']
688
+ let jsDetailFailed = 0
689
+
690
+ for (const cat of JS_ORDER) {
691
+ const { checked, failures } = jsDetailByCategory.get(cat)
692
+ const covered = checked - failures.length
693
+ const mark = failures.length === 0 ? '✓' : '✗'
694
+ console.log(` ${mark} ${cat}: ${covered}/${checked}`)
695
+ jsDetailFailed += failures.length
696
+ }
697
+
698
+ if (jsDetailFailed > 0) {
699
+ console.error(`\n✗ ${jsDetailFailed} JS export doc(s) are missing @param coverage:\n`)
700
+ for (const cat of JS_ORDER) {
701
+ const { failures } = jsDetailByCategory.get(cat)
702
+ if (failures.length === 0) continue
703
+ console.error(`${cat}:`)
704
+ for (const { name, issues, docPath } of failures) {
705
+ const rel = path.relative(ROOT, docPath).replace(/\\/g, '/')
706
+ console.error(` ${name} (${rel}):`)
707
+ for (const issue of issues) {
708
+ console.error(` - ${issue}`)
709
+ }
710
+ }
711
+ console.error('')
712
+ }
713
+ console.error('Add the missing @param names to the doc so every JSDoc parameter is covered.')
714
+ }
715
+
716
+ // ---------------------------------------------------------------------------
717
+ // Final exit
718
+ // ---------------------------------------------------------------------------
719
+
720
+ if (detailFailed > 0 || jsDetailFailed > 0) {
721
+ process.exit(1)
722
+ }
723
+ console.log('\n✓ All accuracy checks passed.')
724
+ process.exit(0)