From d9b256a991e294621904828ab7499935c4450ffd Mon Sep 17 00:00:00 2001 From: Joshua T Kalis Date: Mon, 18 Nov 2024 13:32:14 -0500 Subject: [PATCH] feat(linting): check for docblock in public API closes #103 --- package.json | 2 + pnpm-lock.yaml | 9 ++++ scripts/docblock-is-complete.mjs | 26 +++++++++++ scripts/docblock.mjs | 58 +++++++++++++++++++++++ scripts/file-details.mjs | 80 ++++++++++++++++++++++++++++++++ scripts/pragma-parser.mjs | 50 ++++++++++++++++++++ turbo.json | 5 +- 7 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 scripts/docblock-is-complete.mjs create mode 100644 scripts/docblock.mjs create mode 100644 scripts/file-details.mjs create mode 100644 scripts/pragma-parser.mjs diff --git a/package.json b/package.json index 3059a5d3..26ad2373 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "deps:version-patch": "pnpm taze patch -r", "dev": "turbo dev", "docs": "turbo docs", + "docblocks": "pnpm zx scripts/docblock.mjs", "format": "pnpm biome format . --write", "indexer": "turbo index", "license": "pnpm zx scripts/license.mjs", @@ -35,6 +36,7 @@ "@biomejs/biome": "^1.9.3", "@changesets/changelog-git": "^0.2.0", "@changesets/cli": "^2.27.9", + "comment-parser": "^1.4.1", "@ls-lint/ls-lint": "2.3.0-beta.1", "@swc/core": "^1.7.36", "rimraf": "^5.0.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d156edbc..13c5d817 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@swc/core': specifier: ^1.7.36 version: 1.9.2(@swc/helpers@0.5.15) + comment-parser: + specifier: ^1.4.1 + version: 1.4.1 rimraf: specifier: ^5.0.10 version: 5.0.10 @@ -2926,6 +2929,10 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + comment-parser@1.4.1: + resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==} + engines: {node: '>= 12.0.0'} + confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} @@ -8040,6 +8047,8 @@ snapshots: commander@4.1.1: {} + comment-parser@1.4.1: {} + confbox@0.1.8: {} consola@3.2.3: {} diff --git a/scripts/docblock-is-complete.mjs b/scripts/docblock-is-complete.mjs new file mode 100644 index 00000000..8613cc58 --- /dev/null +++ b/scripts/docblock-is-complete.mjs @@ -0,0 +1,26 @@ +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { parse } from 'comment-parser'; + +export function docblockIsComplete(source) { + const comments = parse(source); + const isComplete = + comments.length && + comments.description && + comments + .flatMap(({ tags }) => tags) + .map(({ tag }) => tag) + .includes('example'); + + return isComplete; +} diff --git a/scripts/docblock.mjs b/scripts/docblock.mjs new file mode 100644 index 00000000..ad35881a --- /dev/null +++ b/scripts/docblock.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env zx + +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { glob } from 'zx'; + +import { docblockIsComplete } from './docblock-is-complete.mjs'; +import { getFileDetails } from './file-details.mjs'; + +const noDocblock = ( + await glob(['**/*.{js,ts,tsx,mjs}'], { + ignore: [ + '**/.github/**', + '**/apps/**', + '**/__fixtures__/**', + '**/__mock__/**', + '**/coverage/**', + '**/dist/**', + '**/node_modules/**', + 'scripts/**', + '**/tooling/**', + '**/*.test*', + '**/*.config*', + '**/*.css*', + '**/*.stories*', + ], + }) +).filter((file) => { + try { + const [source, exports] = getFileDetails(file); + + // has exports and no docblock + return exports.length && !docblockIsComplete(source); + } catch (_) { + // ignore non-parsing files + return false; + } +}); + +if (noDocblock.length) { + console.error( + `${noDocblock.length} files missing a docblock:`, + JSON.stringify(noDocblock, null, 4), + ); + + // TODO: enable error-ing once all file are complying + // process.exit(1); +} diff --git a/scripts/file-details.mjs b/scripts/file-details.mjs new file mode 100644 index 00000000..c9a34cbd --- /dev/null +++ b/scripts/file-details.mjs @@ -0,0 +1,80 @@ +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { parseFileSync } from '@swc/core'; +import { fs } from 'zx'; + +import { pragmaParser } from './pragma-parser.mjs'; + +const SWC_OPTIONS = { + syntax: 'typescript', + target: 'es2022', +}; + +/** Collect the exported members' names. */ +function exportsReducer(acc, member) { + if (member.type === 'ExportDeclaration') { + if (member.declaration.declarations) { + // const, let, var allow for comma separated values + for (const inner of member.declaration.declarations) { + acc.push(inner.id.value); + } + } else { + acc.push( + member.declaration?.identifier?.value || member.declaration?.id?.value, + ); + } + } + + return acc; +} + +/** + * Get the source code of the file and the exported members; taking into + * consideration the pragmas that affect what members will be available in the + * public API: + * + * - `__private-exports` + * - `export-ignore` + * - `export-ignore [x, y, z]` + * - `export-only [a, b, c]` + * + * @returns [string, string[]] // [source, exports] + */ +export function getFileDetails(path, options = {}) { + const ast = parseFileSync(path, { + ...SWC_OPTIONS, + ...options, + }); + const source = fs.readFileSync(path, 'utf8'); + + const exports = ast.body.reduce(exportsReducer, []); + const [pragma, ...list] = pragmaParser(source); + + const result = [source]; + + switch (pragma) { + case 'ignore': + result.push( + list[0] === '*' ? [] : exports.filter((name) => !list.includes(name)), + ); + break; + case 'only': + result.push(exports.filter((name) => list.includes(name))); + break; + default: + result.push(exports); + break; + } + + return result; +} diff --git a/scripts/pragma-parser.mjs b/scripts/pragma-parser.mjs new file mode 100644 index 00000000..f6c21cd0 --- /dev/null +++ b/scripts/pragma-parser.mjs @@ -0,0 +1,50 @@ +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +const rCleaner = /,\s*/; +const rPragma = /\/\/\s*@export-(ignore|only)(?:\s*\[([^\]]+)\])?/; +const rPrivate = /\/\/\s*__private-exports/i; + +/** + * Look for pragmas in the file that would change which exports are considered for the public API: + * + * - `__private-exports` + * - `export-ignore` + * - `export-ignore [x, y, z]` + * - `export-only [a, b, c]` + * + * @returns string[] // [ignore|only, ...members] + */ +export function pragmaParser(src) { + if (src.match(rPrivate)?.[0]) { + return ['ignore', '*']; + } + + const found = src.match(rPragma); + + if (!found) { + return []; + } + + const pragmas = found + .slice(1) + .reduce((a, b = '*') => [ + a, + ...(b ? b.replace(rCleaner, ',').split(',') : b), + ]); + + if (pragmas[0] === 'only' && pragmas[1] === '*') { + return []; + } + + return pragmas; +} diff --git a/turbo.json b/turbo.json index 588ca51e..115a7b5e 100644 --- a/turbo.json +++ b/turbo.json @@ -7,7 +7,10 @@ "persistent": true, "dependsOn": ["index"] }, - "lint": {}, + "docblocks": {}, + "lint": { + "dependsOn": ["docblocks"] + }, "bench": {}, "test": { "dependsOn": ["^build"],