/** * `deno task doc:lint`: runs `deno doc --lint` over the modules given as * arguments and fails on what it reports, with one exception. `deno doc` * doesn't document npm packages, so it calls every type imported from one * private — react-hook-form's `FieldValues` in a form field's props, say — * and no change to our own documentation could satisfy it. A `private-type-ref` * whose type the module imports from a package is let through, and counted; * every other diagnostic fails the run. * * A `missing-jsdoc` on a member (an interface field, a class property) is let * through too: a field gets a doc only when it adds something its type's doc * doesn't, so most fields have none. A module or an export without one fails. * * It also checks what `deno doc --lint` doesn't: that every summary stands on * its own. The reference shows a doc's first paragraph alone, in lists and * above a symbol, so a first paragraph ending in a colon reads cut short. * * @module */ /** One diagnostic of `deno doc --lint`. */ interface Diagnostic { /** The rule, e.g. `missing-jsdoc`. */ rule: string; /** The rule's message. */ message: string; /** The module it was reported in. */ file: string; /** The column it was reported at: 1 for a top-level declaration. */ column: number; /** The diagnostic as printed, location and excerpt included. */ text: string; } /** Returns the diagnostics of `deno doc --lint` over `modules`. */ async function lint(modules: string[]): Promise { const { stderr } = await new Deno.Command(Deno.execPath(), { args: ["doc", "--lint", ...modules], env: { NO_COLOR: "1" }, stdout: "null", stderr: "piped", }).output(); const output = new TextDecoder().decode(stderr); return output.split(/\n(?=error\[)/).flatMap((text) => { const head = /^error\[([\w-]+)\]: (.*)$/m.exec(text); const at = /--> (.+?):\d+:(\d+)/.exec(text); if (!head || !at) return []; return [{ rule: head[1], message: head[2], file: at[1], column: Number(at[2]), text, }]; }); } /** Checks whether module `file` imports `name` from a package. */ function importedFromPackage(file: string, name: string): boolean { const source = Deno.readTextFileSync(file); const imports = source.matchAll( /import\s+(?:type\s+)?([^;]*?)\s+from\s+"([^"]+)"/g, ); for (const [, clause, specifier] of imports) { if (specifier.startsWith(".")) continue; const names = clause.replace(/[{}]/g, " ").split(",").map((part) => part.trim().replace(/^type\s+/, "").split(/\s+as\s+/).pop()!.trim() ); if (names.includes(name)) return true; } return false; } /** * Checks whether `d` is a private type from a package, which is let through. */ function isPackageTypeRef(d: Diagnostic): boolean { const type = /references private type '([\w$]+)/.exec(d.message)?.[1]; return d.rule === "private-type-ref" && !!type && importedFromPackage(d.file, type); } /** Checks whether `d` is a member without a doc, which is let through. */ function isUndocumentedMember(d: Diagnostic): boolean { return d.rule === "missing-jsdoc" && d.column > 1; } /** Returns the first paragraph of `doc`, which the reference shows alone. */ function summary(doc: string): string { return doc.trim().split(/\n\s*\n|\n\s*(?:[-*]|\d+\.)\s|\n\s*```/)[0].trim(); } /** * Returns a diagnostic for every doc among `modules`, module docs included, * whose summary ends in a colon. */ async function colonSummaries(modules: string[]): Promise { const { stdout } = await new Deno.Command(Deno.execPath(), { args: ["doc", "--json", ...modules], stdout: "piped", stderr: "null", }).output(); const { nodes } = JSON.parse(new TextDecoder().decode(stdout)) as { nodes: Record; }; const found: Diagnostic[] = []; const cwd = `file://${Deno.cwd()}/`; function visit(file: string, name: string, value: unknown) { if (Array.isArray(value)) { for (const item of value) visit(file, name, item); return; } if (typeof value !== "object" || value === null) return; const node = value as Record; const at = typeof node.name === "string" ? node.name : name; for (const key of ["jsDoc", "module_doc"]) { const doc = (node[key] as { doc?: string } | undefined)?.doc; if (doc && summary(doc).endsWith(":")) { const what = key === "module_doc" ? "module doc" : `doc of ${at}`; const message = `the ${what} has a summary ending in a colon`; found.push({ rule: "colon-summary", message, file, column: 1, text: `error[colon-summary]: ${message}\n --> ${file}`, }); } } for (const [key, child] of Object.entries(node)) { if (key !== "jsDoc" && key !== "module_doc" && key !== "imports") { visit(file, at, child); } } } for (const [url, module] of Object.entries(nodes)) { visit(url.replace(cwd, ""), "", module); } return found; } if (import.meta.main) { const diagnostics = [ ...await lint(Deno.args), ...await colonSummaries(Deno.args), ]; const failures = diagnostics.filter((d) => !isPackageTypeRef(d) && !isUndocumentedMember(d) ); for (const d of failures) console.error(d.text.trimEnd() + "\n"); const allowed = diagnostics.length - failures.length; console.error( `${failures.length} documentation lint errors` + (allowed ? `, ${allowed} undocumented members and npm type references let through` : ""), ); if (failures.length) Deno.exit(1); }