diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1380729..8b08411 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -88,6 +88,7 @@ jobs: env: PYTHON_PATHS: "python/lsp python/med2obj.py python/mesh_groups.py" JSCPD_PATHS: "src webviews/*/src webviews/shared python/lsp python/med2obj.py python/mesh_groups.py" # test files excluded in .jscpd.json + TS_PATHS: "src webviews/*/src webviews/shared" DOCS_SITE_DIR: "" # no docs website DOCS_BUILD: "" steps: @@ -133,6 +134,192 @@ jobs: EOF fi + if [ -n "$TS_PATHS$GO_PATHS$RUST_PATHS" ]; then + echo "== Documented share of the TypeScript, Go and Rust code (exported items with a doc comment)" + cat > /tmp/docs-count.cjs <<'DOCS' + // Documented share of a language: how many exported items have a doc comment. + // Usage: node docs-count.cjs ... + // Writes {"total": N, "missing": M}, the format of docs-python.json, and prints a summary. + // Embedded in templates/github-actions.yml and templates/gitlab-ci.yml (test_docs_count.py + // checks they are the same). + const fs = require('fs') + const path = require('path') + + const [lang, out, ...roots] = process.argv.slice(2) + + // Tests, build output, vendored and generated code are not API to document. + const SKIP_DIR = + /(^|\/)(node_modules|vendor|dist|build|out|target|coverage|\.next|\.svelte-kit|migrations|tests?|__tests__|testdata|benches)(\/|$)/ + const LANGS = { + ts: { + files: /\.(tsx?|jsx?|mjs|svelte)$/, + skip: /\.(d\.ts|(test|spec|stories)\.[jt]sx?|min\.js)$|\.config\.[mc]?[jt]s$|(^|\/)payload-types\.ts$/, + }, + go: { files: /\.go$/, skip: /(_test|_gen|\.pb)\.go$/ }, + rust: { files: /\.rs$/, skip: /$^/ }, + } + + function files(p, l) { + if (SKIP_DIR.test(p) || l.skip.test(p)) return [] + const stat = fs.statSync(p, { throwIfNoEntry: false }) + if (!stat) return [] + if (stat.isDirectory()) return fs.readdirSync(p).flatMap((f) => files(path.join(p, f), l)) + return l.files.test(p) ? [p] : [] + } + + // TypeScript, JavaScript, JSX/TSX and the scripts of Svelte components, with the TypeScript compiler. + function tsCounter() { + const ts = require('typescript') + const K = ts.SyntaxKind + const exportedBy = (n) => ts.getModifiers(n)?.some((m) => m.kind === K.ExportKeyword) + const functionLike = (n) => n && (ts.isArrowFunction(n) || ts.isFunctionExpression(n) || ts.isClassExpression(n)) + // memo(() => ...), forwardRef(function ...): the usual way to write a React component + const component = (n) => functionLike(n) || (n && ts.isCallExpression(n) && n.arguments.some(functionLike)) + const documented = (n) => + ts.getJSDocCommentsAndTags(n).some((d) => ts.isJSDoc(d) && (d.comment?.length || d.tags?.length)) + const hidden = (m) => ts.getModifiers(m)?.some((x) => x.kind === K.PrivateKeyword || x.kind === K.ProtectedKeyword) + + function code(text, kind) { + const source = ts.createSourceFile('x', text, ts.ScriptTarget.Latest, true, kind) + const declared = new Map() // top-level name -> {node (where the JSDoc is), class?} + const items = new Map() // exported item -> node carrying its JSDoc + const reexported = [] + for (const node of source.statements) { + if ( + ts.isFunctionDeclaration(node) || + ts.isClassDeclaration(node) || + ts.isInterfaceDeclaration(node) || + ts.isTypeAliasDeclaration(node) || + ts.isEnumDeclaration(node) + ) { + if (ts.isFunctionDeclaration(node) && !node.body) continue // overload signatures + const entry = { node, cls: ts.isClassDeclaration(node) ? node : null } + if (node.name) declared.set(node.name.text, entry) + if (exportedBy(node)) items.set(node, entry) + } else if (ts.isVariableStatement(node)) { + for (const d of node.declarationList.declarations) { + if (!ts.isIdentifier(d.name) || !component(d.initializer)) continue + const entry = { node, cls: ts.isClassExpression(d.initializer) ? d.initializer : null } + declared.set(d.name.text, entry) + if (exportedBy(node)) items.set(d, entry) + } + } else if (ts.isExportAssignment(node) && ts.isIdentifier(node.expression)) { + reexported.push(node.expression.text) // export default Page + } else if (ts.isExportDeclaration(node) && !node.moduleSpecifier && node.exportClause?.elements) { + for (const e of node.exportClause.elements) reexported.push((e.propertyName ?? e.name).text) // export { a, b } + } + } + for (const name of reexported) { + const entry = declared.get(name) + if (entry && ![...items.values()].includes(entry)) items.set(entry.node, entry) + } + let total = 0 + let missing = 0 + for (const { node, cls } of items.values()) { + total += 1 + if (!documented(node)) missing += 1 + // public methods of an exported class + for (const m of cls?.members ?? []) { + if (!ts.isMethodDeclaration(m) || hidden(m) || /^[_#]/.test(m.name.getText())) continue + total += 1 + if (!documented(m)) missing += 1 + } + } + return { total, missing } + } + + return (file) => { + const text = fs.readFileSync(file, 'utf8') + if (!file.endsWith('.svelte')) { + const kind = /\.tsx$/.test(file) + ? ts.ScriptKind.TSX + : /\.jsx$/.test(file) + ? ts.ScriptKind.JSX + : /\.m?js$/.test(file) + ? ts.ScriptKind.JS + : ts.ScriptKind.TS + return code(text, kind) + } + // A component is one item, documented by a comment opening the file or its script; the + // functions it exports from its scripts count like any module. + const scripts = [...text.matchAll(/]*>([\s\S]*?)<\/script>/g)].map((m) => m[1]) + const header = /^\s*