From 3055cd43e4c8bcd182cebdfc8e28f3b20203dff5 Mon Sep 17 00:00:00 2001 From: Ulysse Bouchet Date: Mon, 26 Oct 2026 11:17:20 +0100 Subject: [PATCH 1/2] ci(quality): measure the documented share of the TypeScript code The quality-reports job counts the exported functions, classes, types and components of src and webviews that have a JSDoc comment (docs-ts.json), next to the Python docstrings. Today: 78 of 175. --- .github/workflows/test.yml | 54 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1380729..b347844 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,59 @@ jobs: EOF fi + if [ -n "$TS_PATHS" ]; then + echo "== TypeScript documented share (exported items with a JSDoc comment)" + npm install --silent --no-audit --no-fund --prefix /tmp/tsdocs typescript@5.9.3 > /dev/null && + cat > /tmp/ts-docs.cjs <<'TSDOCS' && + const ts = require('typescript') + const fs = require('fs') + const path = require('path') + + // Type declarations, tests, build output and generated code are not API to document. + const SKIP = + /(^|\/)(node_modules|dist|build|out|coverage|\.next|\.svelte-kit|migrations|tests?|__tests__)(\/|$)|\.(d|test|spec)\.tsx?$|(^|\/)payload-types\.ts$/ + + function files(p) { + if (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))) + return /\.tsx?$/.test(p) ? [p] : [] + } + + const exported = (n) => ts.getModifiers(n)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) + const functionLike = (n) => + n && (ts.isArrowFunction(n) || ts.isFunctionExpression(n) || ts.isClassExpression(n)) + const documented = (n) => + ts.getJSDocCommentsAndTags(n).some((d) => ts.isJSDoc(d) && (d.comment?.length || d.tags?.length)) + + let total = 0 + let missing = 0 + for (const file of process.argv.slice(2).flatMap(files)) { + const kind = file.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS + const source = ts.createSourceFile(file, fs.readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true, kind) + for (const node of source.statements) { + if (!exported(node)) continue + let items = 0 + if (ts.isFunctionDeclaration(node)) items = node.body ? 1 : 0 // overload signatures are not counted + else if (ts.isClassDeclaration(node) || ts.isInterfaceDeclaration(node)) items = 1 + else if (ts.isTypeAliasDeclaration(node) || ts.isEnumDeclaration(node)) items = 1 + else if (ts.isVariableStatement(node)) + items = node.declarationList.declarations.filter((d) => functionLike(d.initializer)).length + total += items + if (!documented(node)) missing += items + } + } + if (total) { + fs.mkdirSync('reports', { recursive: true }) + fs.writeFileSync('reports/docs-ts.json', JSON.stringify({ total, missing })) + } + console.log(`${total - missing} of ${total} exported items documented`) + TSDOCS + NODE_PATH=/tmp/tsdocs/node_modules node /tmp/ts-docs.cjs $TS_PATHS || + echo "skipped" + fi + if [ -n "$DOCS_SITE_DIR" ]; then echo "== Broken links of the docs website (lychee)" { [ -z "$DOCS_BUILD" ] || sh -c "$DOCS_BUILD"; } && From e32ecf87230f9046ed6e1660a9ca65da149f63d6 Mon Sep 17 00:00:00 2001 From: Ulysse Bouchet Date: Mon, 26 Oct 2026 11:32:37 +0100 Subject: [PATCH 2/2] ci(quality): document share also reads Svelte, JSX and class methods The quality-reports job now runs the shared docs-count script: exported items of .ts, .tsx, .js, .jsx and Svelte components, public methods of exported classes, components wrapped in memo or forwardRef, export default Name and export { Name }. Today: 86 of 183. --- .github/workflows/test.yml | 207 ++++++++++++++++++++++++++++++------- 1 file changed, 170 insertions(+), 37 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b347844..8b08411 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -134,57 +134,190 @@ jobs: EOF fi - if [ -n "$TS_PATHS" ]; then - echo "== TypeScript documented share (exported items with a JSDoc comment)" - npm install --silent --no-audit --no-fund --prefix /tmp/tsdocs typescript@5.9.3 > /dev/null && - cat > /tmp/ts-docs.cjs <<'TSDOCS' && - const ts = require('typescript') + 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') - // Type declarations, tests, build output and generated code are not API to document. - const SKIP = - /(^|\/)(node_modules|dist|build|out|coverage|\.next|\.svelte-kit|migrations|tests?|__tests__)(\/|$)|\.(d|test|spec)\.tsx?$|(^|\/)payload-types\.ts$/ + const [lang, out, ...roots] = process.argv.slice(2) - function files(p) { - if (SKIP.test(p)) return [] + // 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))) - return /\.tsx?$/.test(p) ? [p] : [] + 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*