mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Gate JSDoc completeness on every package export
New doc-sync gate verify-export-jsdoc walks every module-level exported name under packages/*/*/src and requires description prose everywhere, plus @param per parameter and @returns on non-void annotated returns for function-like exports, public class methods, properties, and accessors. The parsing + check helpers move out of gen-cordis-catalog.ts into a shared scripts/jsdoc.ts so 'documented' means one thing on both gated surfaces. Deliberate exemptions (documented in the RFC): heritage-declared class members (the seam declaration is the doc's one home — the one checker query in an otherwise pure-AST walk), cordis plugin-protocol slots (name/inject/reusable/Config/apply, top-level and static), constructors, overload implementations, declare-module augmentation bodies, and re-export statements (checked at the defining module). The 203 under-documented exports the gate found at adoption are filled in this change, so the gate lands green; generated catalogs/graphs are regenerated for the shifted line pointers. RFC: docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md
This commit is contained in:
@@ -40,7 +40,9 @@
|
||||
* a stale `@param` naming no real parameter errors. Violations aggregate into
|
||||
* ONE error listing every offender. The tags are enforcement-only: parseJsDoc
|
||||
* stops prose at the first block tag, so they never change the rendered
|
||||
* catalog. The INHERITED
|
||||
* catalog. The parsing + check helpers live in `scripts/jsdoc.ts`, shared with
|
||||
* the whole-export-surface gate (`scripts/verify-export-jsdoc.ts`) so
|
||||
* "documented" means the same thing on both surfaces. The INHERITED
|
||||
* tier (cordis core + loader/hmr/timer) is pinned vendor source a plugin author
|
||||
* also sees; it is rendered tersely (name + one-line + source pointer) from a
|
||||
* curated table in this script, NOT elevated to the harness tier's prominence.
|
||||
@@ -53,6 +55,7 @@
|
||||
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT_EVENTS = 'docs/cordis-catalog/events.md'
|
||||
@@ -62,9 +65,6 @@ const OUT_SERVICES = 'docs/cordis-catalog/services.md'
|
||||
* doc-typecheck, since a bare signature fragment is not standalone-compilable). */
|
||||
const FENCE = 'ts cordis-catalog'
|
||||
|
||||
/** A dispatch mode, rendered as the badge after an event name. */
|
||||
type Mode = 'emit' | 'waterfall' | 'parallel' | 'serial'
|
||||
|
||||
/**
|
||||
* Cross-link map: a type name that appears in a signature → the
|
||||
* core-data-structures page that documents it (path relative to the catalogs'
|
||||
@@ -146,132 +146,6 @@ interface InheritedEntry {
|
||||
source: string
|
||||
}
|
||||
|
||||
/** Repo-relative source pointer `file:line` for a node's first character. */
|
||||
function pointer(rel: string, sf: ts.SourceFile, node: ts.Node): string {
|
||||
const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf))
|
||||
return `${rel}:${line + 1}`
|
||||
}
|
||||
|
||||
/** The raw `/** … */` JSDoc block immediately preceding a node, or '' if none. */
|
||||
function rawJsDoc(text: string, node: ts.Node): string {
|
||||
const ranges = ts.getLeadingCommentRanges(text, node.getFullStart()) ?? []
|
||||
const jsdoc = ranges.filter(r => text.slice(r.pos, r.pos + 3) === '/**').at(-1)
|
||||
return jsdoc ? text.slice(jsdoc.pos, jsdoc.end) : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw JSDoc block into description prose + the `@mode` tag (when
|
||||
* present). Output obeys the repo's markdown conventions so the generated file
|
||||
* passes verify-md-wrap: each prose paragraph collapses to ONE physical line,
|
||||
* and a `-` bullet list is preserved with each item on its own single line
|
||||
* (continuation lines folded in). `{@link Foo}` unwraps to `Foo`. Description
|
||||
* prose ends at the FIRST block tag (standard JSDoc semantics): tag lines and
|
||||
* their continuation lines are never prose, so `@param`/`@returns` blocks are
|
||||
* invisible to the rendered catalog.
|
||||
*/
|
||||
function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
|
||||
const inner = raw
|
||||
.replace(/^\/\*\*/, '')
|
||||
.replace(/\*\/$/, '')
|
||||
.split('\n')
|
||||
.map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
|
||||
let mode: Mode | null = null
|
||||
let inTags = false
|
||||
const blocks: string[] = []
|
||||
let para: string[] = []
|
||||
let list: string[] = []
|
||||
let item: string[] = []
|
||||
const join = (parts: string[]): string => parts.join(' ').replace(/\s+/g, ' ').trim()
|
||||
const flushItem = (): void => {
|
||||
if (item.length) list.push(join(item))
|
||||
item = []
|
||||
}
|
||||
const flushList = (): void => {
|
||||
flushItem()
|
||||
if (list.length) blocks.push(list.join('\n')) // one block, items on own lines
|
||||
list = []
|
||||
}
|
||||
const flushPara = (): void => {
|
||||
flushList()
|
||||
if (para.length) blocks.push(join(para))
|
||||
para = []
|
||||
}
|
||||
for (const line of inner) {
|
||||
const m = /^@mode\s+(emit|waterfall|parallel|serial)\s*$/.exec(line)
|
||||
if (m) { mode = m[1] as Mode; flushPara(); inTags = true; continue }
|
||||
if (line.startsWith('@')) { flushPara(); inTags = true; continue }
|
||||
if (inTags) continue // block-tag territory: continuations are never prose
|
||||
if (line.trim() === '') { flushPara(); continue }
|
||||
if (/^-\s+/.test(line)) {
|
||||
// A list item starts: a pending paragraph (e.g. an intro line directly
|
||||
// above the list, no blank between) flushes FIRST so it renders above.
|
||||
flushItem()
|
||||
if (para.length) { blocks.push(join(para)); para = [] }
|
||||
item.push(line)
|
||||
continue
|
||||
}
|
||||
if (item.length) { item.push(line); continue } // continuation of current item
|
||||
para.push(line)
|
||||
}
|
||||
flushPara()
|
||||
const doc = blocks.join('\n\n').replace(/\{@link\s+([^}]+)\}/g, '$1').trim()
|
||||
return { doc, mode }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the block tags of a raw JSDoc comment for the completeness checks:
|
||||
* every `@param name — description` entry plus the `@returns` description.
|
||||
* Standard JSDoc block-tag semantics — a tag's description runs across
|
||||
* continuation lines until the next tag or a blank line, and the `-`/`—`
|
||||
* separator after a param name is optional. `[name]` optional-brackets unwrap
|
||||
* to `name`. Rendering never sees these: parseJsDoc stops prose at the first
|
||||
* block tag.
|
||||
*/
|
||||
function parseTags(raw: string): { params: Map<string, string>; returns: string | null } {
|
||||
const inner = raw
|
||||
.replace(/^\/\*\*/, '')
|
||||
.replace(/\*\/$/, '')
|
||||
.split('\n')
|
||||
.map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
|
||||
const params = new Map<string, string>()
|
||||
let returns: string | null = null
|
||||
let sink: ((text: string) => void) | null = null
|
||||
for (const line of inner) {
|
||||
const param = /^@param\s+(\[?[\w$]+\]?)\s*(?:[-—–]\s*)?(.*)$/.exec(line)
|
||||
if (param) {
|
||||
const name = (param[1] ?? '').replace(/^\[|\]$/g, '')
|
||||
let acc = param[2] ?? ''
|
||||
params.set(name, acc)
|
||||
sink = (t) => { acc = acc ? `${acc} ${t}` : t; params.set(name, acc) }
|
||||
continue
|
||||
}
|
||||
const ret = /^@returns?(?:\s+[-—–]?\s*(.*))?$/.exec(line)
|
||||
if (ret) {
|
||||
let acc = ret[1] ?? ''
|
||||
returns = acc
|
||||
sink = (t) => { acc = acc ? `${acc} ${t}` : t; returns = acc }
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('@') || line.trim() === '') { sink = null; continue }
|
||||
sink?.(line.trim())
|
||||
}
|
||||
return { params, returns }
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw one aggregate error for every completeness violation a walk collected.
|
||||
* Aggregation (vs the fail-fast the @mode check used to do) is deliberate: a
|
||||
* remediation pass sees the whole list at once instead of replaying the gate
|
||||
* once per offender.
|
||||
*/
|
||||
function reportViolations(violations: string[]): void {
|
||||
if (violations.length === 0) return
|
||||
throw new Error(
|
||||
`gen-cordis-catalog: ${violations.length} JSDoc completeness violation(s) (see AGENTS.md):\n`
|
||||
+ violations.map(v => ` ${v}`).join('\n'),
|
||||
)
|
||||
}
|
||||
|
||||
/** Find the `declare module 'cordis'` body in a source file, or null. */
|
||||
function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
|
||||
for (const stmt of sf.statements) {
|
||||
@@ -334,27 +208,13 @@ export function collectEvents(scanRoot: string = root): EventEntry[] {
|
||||
// (mode machinery, documented once by @mode semantics). Documenting an
|
||||
// exempt parameter anyway is allowed — only absence is checked.
|
||||
const { params } = parseTags(raw)
|
||||
for (const p of member.parameters) {
|
||||
if (!ts.isIdentifier(p.name)) {
|
||||
violations.push(`${where}: parameter '${p.name.getText(sf)}' is a binding pattern; the event surface needs simple identifier parameters so @param can name them.`)
|
||||
continue
|
||||
}
|
||||
const pname = p.name.text
|
||||
if (pname === 'this' || (hasNext && p === last)) continue
|
||||
const desc = params.get(pname)
|
||||
if (desc === undefined) violations.push(`${where} is missing @param ${pname}.`)
|
||||
else if (!desc.trim()) violations.push(`${where}: @param ${pname} has an empty description.`)
|
||||
}
|
||||
for (const tag of params.keys()) {
|
||||
if (!member.parameters.some(p => ts.isIdentifier(p.name) && p.name.text === tag)) {
|
||||
violations.push(`${where}: @param ${tag} does not match any parameter (stale tag?).`)
|
||||
}
|
||||
}
|
||||
checkParams(where, 'event', member.parameters, params, sf,
|
||||
p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations)
|
||||
if (mode) entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src })
|
||||
}
|
||||
}
|
||||
}
|
||||
reportViolations(violations)
|
||||
reportViolations('gen-cordis-catalog', violations)
|
||||
return entries
|
||||
}
|
||||
|
||||
@@ -415,35 +275,12 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
|
||||
if (!raw) { violations.push(`${where} has no JSDoc.`); continue }
|
||||
if (!parseJsDoc(raw).doc) violations.push(`${where} has no description prose above its block tags.`)
|
||||
const { params, returns } = parseTags(raw)
|
||||
// Every parameter needs a non-empty @param; a `this` receiver
|
||||
// annotation is not payload and is exempt.
|
||||
for (const p of member.parameters) {
|
||||
if (!ts.isIdentifier(p.name)) {
|
||||
violations.push(`${where}: parameter '${p.name.getText(sf)}' is a binding pattern; the service surface needs simple identifier parameters so @param can name them.`)
|
||||
continue
|
||||
}
|
||||
const pname = p.name.text
|
||||
if (pname === 'this') continue
|
||||
const desc = params.get(pname)
|
||||
if (desc === undefined) violations.push(`${where} is missing @param ${pname}.`)
|
||||
else if (!desc.trim()) violations.push(`${where}: @param ${pname} has an empty description.`)
|
||||
}
|
||||
for (const tag of params.keys()) {
|
||||
if (!member.parameters.some(p => ts.isIdentifier(p.name) && p.name.text === tag)) {
|
||||
violations.push(`${where}: @param ${tag} does not match any parameter (stale tag?).`)
|
||||
}
|
||||
}
|
||||
// A non-void result needs a non-empty @returns. The return type must be
|
||||
// ANNOTATED: a pure-AST walk cannot classify an inferred return. On a
|
||||
// `void`/`Promise<void>` method @returns stays optional (resolution
|
||||
// timing can be worth documenting), never required.
|
||||
const rt = member.type?.getText(sf).replace(/\s+/g, ' ')
|
||||
if (rt === undefined) {
|
||||
violations.push(`${where} has no return type annotation; annotate it explicitly so the gate can classify the result.`)
|
||||
} else if (!/^(void|Promise<void>)$/.test(rt)) {
|
||||
if (returns === null) violations.push(`${where} is missing @returns (return type: ${rt}).`)
|
||||
else if (!returns.trim()) violations.push(`${where}: @returns has an empty description.`)
|
||||
}
|
||||
// Every parameter needs a non-empty @param (`this` receiver exempt),
|
||||
// and a non-void ANNOTATED result needs a non-empty @returns — the
|
||||
// shared checkers carry the exact contract.
|
||||
checkParams(where, 'service', member.parameters, params, sf,
|
||||
p => ts.isIdentifier(p.name) && p.name.text === 'this', violations)
|
||||
checkReturns(where, member.type, returns, sf, violations)
|
||||
}
|
||||
entries.push({
|
||||
key,
|
||||
@@ -455,7 +292,7 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
|
||||
})
|
||||
}
|
||||
}
|
||||
reportViolations(violations)
|
||||
reportViolations('gen-cordis-catalog', violations)
|
||||
return entries.sort((a, b) => a.key.localeCompare(b.key))
|
||||
}
|
||||
|
||||
|
||||
216
scripts/jsdoc.ts
Normal file
216
scripts/jsdoc.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Shared JSDoc parsing and completeness-check helpers for the documentation
|
||||
* gates: the cordis catalog generator (`scripts/gen-cordis-catalog.ts` — the
|
||||
* events + `ctx.<key>` service surface) and the export-surface gate
|
||||
* (`scripts/verify-export-jsdoc.ts` — every module-level export). One home for
|
||||
* the mechanics so "documented" means the same thing on every gated surface:
|
||||
* description prose ends at the first block tag; every checkable parameter
|
||||
* needs a non-empty `@param`; a non-void ANNOTATED return needs a non-empty
|
||||
* `@returns`; a stale `@param` naming no real parameter errors.
|
||||
*/
|
||||
|
||||
import ts from 'typescript'
|
||||
|
||||
/** Repo-relative source pointer `file:line` for a node's first character. */
|
||||
export function pointer(rel: string, sf: ts.SourceFile, node: ts.Node): string {
|
||||
const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf))
|
||||
return `${rel}:${line + 1}`
|
||||
}
|
||||
|
||||
/** The raw `/** … */` JSDoc block immediately preceding a node, or '' if none. */
|
||||
export function rawJsDoc(text: string, node: ts.Node): string {
|
||||
const ranges = ts.getLeadingCommentRanges(text, node.getFullStart()) ?? []
|
||||
const jsdoc = ranges.filter(r => text.slice(r.pos, r.pos + 3) === '/**').at(-1)
|
||||
return jsdoc ? text.slice(jsdoc.pos, jsdoc.end) : ''
|
||||
}
|
||||
|
||||
/** A dispatch mode, rendered as the badge after an event name in the catalog. */
|
||||
export type Mode = 'emit' | 'waterfall' | 'parallel' | 'serial'
|
||||
|
||||
/**
|
||||
* Parse a raw JSDoc block into description prose + the `@mode` tag (when
|
||||
* present). Output obeys the repo's markdown conventions so the generated
|
||||
* catalog passes verify-md-wrap: each prose paragraph collapses to ONE physical
|
||||
* line, and a `-` bullet list is preserved with each item on its own single
|
||||
* line (continuation lines folded in). `{@link Foo}` unwraps to `Foo`.
|
||||
* Description prose ends at the FIRST block tag (standard JSDoc semantics):
|
||||
* tag lines and their continuation lines are never prose, so `@param` /
|
||||
* `@returns` blocks are invisible to the rendered catalog.
|
||||
* @param raw - the raw comment text including the JSDoc delimiters.
|
||||
* @returns the collapsed description prose plus the parsed `@mode` (or null).
|
||||
*/
|
||||
export function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
|
||||
const inner = raw
|
||||
.replace(/^\/\*\*/, '')
|
||||
.replace(/\*\/$/, '')
|
||||
.split('\n')
|
||||
.map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
|
||||
let mode: Mode | null = null
|
||||
let inTags = false
|
||||
const blocks: string[] = []
|
||||
let para: string[] = []
|
||||
let list: string[] = []
|
||||
let item: string[] = []
|
||||
const join = (parts: string[]): string => parts.join(' ').replace(/\s+/g, ' ').trim()
|
||||
const flushItem = (): void => {
|
||||
if (item.length) list.push(join(item))
|
||||
item = []
|
||||
}
|
||||
const flushList = (): void => {
|
||||
flushItem()
|
||||
if (list.length) blocks.push(list.join('\n')) // one block, items on own lines
|
||||
list = []
|
||||
}
|
||||
const flushPara = (): void => {
|
||||
flushList()
|
||||
if (para.length) blocks.push(join(para))
|
||||
para = []
|
||||
}
|
||||
for (const line of inner) {
|
||||
const m = /^@mode\s+(emit|waterfall|parallel|serial)\s*$/.exec(line)
|
||||
if (m) { mode = m[1] as Mode; flushPara(); inTags = true; continue }
|
||||
if (line.startsWith('@')) { flushPara(); inTags = true; continue }
|
||||
if (inTags) continue // block-tag territory: continuations are never prose
|
||||
if (line.trim() === '') { flushPara(); continue }
|
||||
if (/^-\s+/.test(line)) {
|
||||
// A list item starts: a pending paragraph (e.g. an intro line directly
|
||||
// above the list, no blank between) flushes FIRST so it renders above.
|
||||
flushItem()
|
||||
if (para.length) { blocks.push(join(para)); para = [] }
|
||||
item.push(line)
|
||||
continue
|
||||
}
|
||||
if (item.length) { item.push(line); continue } // continuation of current item
|
||||
para.push(line)
|
||||
}
|
||||
flushPara()
|
||||
const doc = blocks.join('\n\n').replace(/\{@link\s+([^}]+)\}/g, '$1').trim()
|
||||
return { doc, mode }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the block tags of a raw JSDoc comment for the completeness checks:
|
||||
* every `@param name — description` entry plus the `@returns` description.
|
||||
* Standard JSDoc block-tag semantics — a tag's description runs across
|
||||
* continuation lines until the next tag or a blank line, and the `-`/`—`
|
||||
* separator after a param name is optional. `[name]` optional-brackets unwrap
|
||||
* to `name`. Rendering never sees these: parseJsDoc stops prose at the first
|
||||
* block tag.
|
||||
* @param raw - the raw comment text including the JSDoc delimiters.
|
||||
* @returns the `@param` name→description map plus the `@returns` description
|
||||
* (null when the tag is absent, '' when present but empty).
|
||||
*/
|
||||
export function parseTags(raw: string): { params: Map<string, string>; returns: string | null } {
|
||||
const inner = raw
|
||||
.replace(/^\/\*\*/, '')
|
||||
.replace(/\*\/$/, '')
|
||||
.split('\n')
|
||||
.map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
|
||||
const params = new Map<string, string>()
|
||||
let returns: string | null = null
|
||||
let sink: ((text: string) => void) | null = null
|
||||
for (const line of inner) {
|
||||
const param = /^@param\s+(\[?[\w$]+\]?)\s*(?:[-—–]\s*)?(.*)$/.exec(line)
|
||||
if (param) {
|
||||
const name = (param[1] ?? '').replace(/^\[|\]$/g, '')
|
||||
let acc = param[2] ?? ''
|
||||
params.set(name, acc)
|
||||
sink = (t) => { acc = acc ? `${acc} ${t}` : t; params.set(name, acc) }
|
||||
continue
|
||||
}
|
||||
const ret = /^@returns?(?:\s+[-—–]?\s*(.*))?$/.exec(line)
|
||||
if (ret) {
|
||||
let acc = ret[1] ?? ''
|
||||
returns = acc
|
||||
sink = (t) => { acc = acc ? `${acc} ${t}` : t; returns = acc }
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('@') || line.trim() === '') { sink = null; continue }
|
||||
sink?.(line.trim())
|
||||
}
|
||||
return { params, returns }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the `@param` half of the completeness contract for one function-like
|
||||
* declaration: every checkable parameter carries a non-empty `@param`, and no
|
||||
* `@param` is stale. A binding-pattern parameter is a violation (it has no name
|
||||
* for `@param` to match); an exempt parameter may be documented but its absence
|
||||
* is never checked. Violations append to `violations` in place.
|
||||
* @param where - the offender label violations open with, e.g. `event 'x' (file:1)`.
|
||||
* @param surface - the surface noun for the binding-pattern message ("event", "service", "export").
|
||||
* @param parameters - the declaration's parameter list.
|
||||
* @param tags - the parsed `@param` name→description map from parseTags.
|
||||
* @param sf - the source file (for rendering a binding pattern's text).
|
||||
* @param isExempt - which parameters need no `@param` (e.g. `this`, a waterfall's trailing `next`).
|
||||
* @param violations - the aggregate list violations append to.
|
||||
*/
|
||||
export function checkParams(
|
||||
where: string,
|
||||
surface: string,
|
||||
parameters: readonly ts.ParameterDeclaration[],
|
||||
tags: Map<string, string>,
|
||||
sf: ts.SourceFile,
|
||||
isExempt: (p: ts.ParameterDeclaration) => boolean,
|
||||
violations: string[],
|
||||
): void {
|
||||
for (const p of parameters) {
|
||||
if (!ts.isIdentifier(p.name)) {
|
||||
violations.push(`${where}: parameter '${p.name.getText(sf)}' is a binding pattern; the ${surface} surface needs simple identifier parameters so @param can name them.`)
|
||||
continue
|
||||
}
|
||||
if (isExempt(p)) continue
|
||||
const desc = tags.get(p.name.text)
|
||||
if (desc === undefined) violations.push(`${where} is missing @param ${p.name.text}.`)
|
||||
else if (!desc.trim()) violations.push(`${where}: @param ${p.name.text} has an empty description.`)
|
||||
}
|
||||
for (const tag of tags.keys()) {
|
||||
if (!parameters.some(p => ts.isIdentifier(p.name) && p.name.text === tag)) {
|
||||
violations.push(`${where}: @param ${tag} does not match any parameter (stale tag?).`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the `@returns` half of the completeness contract: a non-`void` /
|
||||
* `Promise<void>` return needs a non-empty `@returns`, and the return type must
|
||||
* be ANNOTATED — a pure-AST walk cannot classify an inferred return. On a void
|
||||
* declaration `@returns` stays optional (resolution timing can be worth
|
||||
* documenting), never required. Violations append to `violations` in place.
|
||||
* @param where - the offender label violations open with.
|
||||
* @param typeNode - the declared return type annotation, or undefined when inferred.
|
||||
* @param returns - the parsed `@returns` description from parseTags (null when absent).
|
||||
* @param sf - the source file (for rendering the annotation's text).
|
||||
* @param violations - the aggregate list violations append to.
|
||||
*/
|
||||
export function checkReturns(
|
||||
where: string,
|
||||
typeNode: ts.TypeNode | undefined,
|
||||
returns: string | null,
|
||||
sf: ts.SourceFile,
|
||||
violations: string[],
|
||||
): void {
|
||||
if (typeNode === undefined) {
|
||||
violations.push(`${where} has no return type annotation; annotate it explicitly so the gate can classify the result.`)
|
||||
return
|
||||
}
|
||||
const rt = typeNode.getText(sf).replace(/\s+/g, ' ')
|
||||
if (/^(void|Promise<void>)$/.test(rt)) return
|
||||
if (returns === null) violations.push(`${where} is missing @returns (return type: ${rt}).`)
|
||||
else if (!returns.trim()) violations.push(`${where}: @returns has an empty description.`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw one aggregate error for every completeness violation a walk collected.
|
||||
* Aggregation (vs failing fast) is deliberate: a remediation pass sees the
|
||||
* whole list at once instead of replaying the gate once per offender.
|
||||
* @param gate - the reporting gate's name, prefixed to the error message.
|
||||
* @param violations - the collected violation lines; no-op when empty.
|
||||
*/
|
||||
export function reportViolations(gate: string, violations: string[]): void {
|
||||
if (violations.length === 0) return
|
||||
throw new Error(
|
||||
`${gate}: ${violations.length} JSDoc completeness violation(s) (see AGENTS.md):\n`
|
||||
+ violations.map(v => ` ${v}`).join('\n'),
|
||||
)
|
||||
}
|
||||
408
scripts/verify-export-jsdoc.ts
Normal file
408
scripts/verify-export-jsdoc.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* Verify JSDoc completeness for EVERY module-level exported name of every
|
||||
* non-vendored package (each `packages/<group>/<pkg>/src/` tree). This is the
|
||||
* mechanical form of the AGENTS.md rule "every export has a JSDoc explaining
|
||||
* semantics", generalizing the cordis-surface gate (`gen-cordis-catalog.ts`,
|
||||
* which owns `interface Events` members and `ctx.<key>` service classes) to
|
||||
* the whole export surface; the parsing + check helpers are shared via
|
||||
* `scripts/jsdoc.ts` so "documented" means the same thing on both.
|
||||
*
|
||||
* `tsx scripts/verify-export-jsdoc.ts` → exit 1 listing every offender
|
||||
*
|
||||
* The contract, per exported declaration kind:
|
||||
*
|
||||
* - Every exported name needs JSDoc with non-empty description prose (prose
|
||||
* ends at the first block tag, standard JSDoc semantics).
|
||||
* - A function-like export (function declaration, or a const with a function
|
||||
* initializer) additionally needs a non-empty `@param` per parameter
|
||||
* (`this` receiver annotations exempt; a stale `@param` errors) and a
|
||||
* non-empty `@returns` unless the return type is `void`/`Promise<void>`.
|
||||
* The walk classifies returns syntactically, so the return type must be
|
||||
* ANNOTATED — except a const whose DECLARATOR is type-annotated (e.g.
|
||||
* `export const f: Handler = …`), where the named type owns the return
|
||||
* contract and `@returns` stays optional.
|
||||
* - An exported class needs class-level JSDoc; its public methods (static
|
||||
* included — they are reachable on the exported name) follow the function
|
||||
* contract, and public properties and accessors need description prose (on
|
||||
* a get/set pair the getter's doc covers both). A member whose name exists
|
||||
* on an `extends`/`implements` heritage type is EXEMPT — the seam
|
||||
* declaration is the doc's one home, the IDE inherits it, and re-documenting
|
||||
* every implementation invites drift. This is the one question the walk
|
||||
* asks the TYPE CHECKER (heritage members live across package boundaries);
|
||||
* everything else is pure AST. Constructors are exempt like the cordis
|
||||
* gate's: plugin classes are framework-constructed, and the class doc owns
|
||||
* the story.
|
||||
* - Exported interfaces, type aliases, enums: description prose on the
|
||||
* declaration (member-level docs stay review's job; the highest-value
|
||||
* member surface — seam service classes — is already under the cordis
|
||||
* gate).
|
||||
* - An exported namespace recurses (its exported members are package
|
||||
* surface); the namespace itself needs prose only when it does not merge
|
||||
* with an already-documented same-name declaration (the Config-namespace
|
||||
* idiom documents the class/function once, not twice).
|
||||
* - The cordis plugin-protocol slots are exempt: top-level `name` / `inject`
|
||||
* / `reusable` / `Config` consts and the `apply` entry, plus the same
|
||||
* slots as statics on a plugin class. Their shape is fixed by the
|
||||
* framework, so a doc would restate the protocol — the module doc comment
|
||||
* and the `interface Config` carry the plugin's real semantics. (These
|
||||
* names are reserved by cordis convention; documenting one anyway is
|
||||
* allowed, only absence goes unchecked.)
|
||||
* - Overload groups: each overload signature carries its own docs; the
|
||||
* implementation signature is exempt (callers never see it).
|
||||
* - Skipped: `declare module` / `declare global` augmentation bodies (the
|
||||
* cordis gate's turf; an augmentation is not an export of the package) and
|
||||
* re-export statements with a module specifier (`export … from`) — the
|
||||
* defining module is walked on its own, and external definitions are not
|
||||
* ours to document.
|
||||
*/
|
||||
|
||||
import { existsSync, globSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc } from './jsdoc.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
/** Plugin-protocol slot names exempt as statics on an exported class. */
|
||||
const PROTOCOL_STATICS = new Set(['Config', 'inject', 'name', 'reusable'])
|
||||
|
||||
/** Plugin-protocol slot names exempt as top-level exports (const or function). */
|
||||
const PROTOCOL_EXPORTS = new Set(['Config', 'inject', 'name', 'reusable', 'apply'])
|
||||
|
||||
/** Per-file walk state threaded through the scope recursion. */
|
||||
interface Walk {
|
||||
/** Repo-relative path of the file being walked. */
|
||||
rel: string
|
||||
/** The parsed source file. */
|
||||
sf: ts.SourceFile
|
||||
/** Raw file text (rawJsDoc reads comment ranges out of it). */
|
||||
text: string
|
||||
/** The program's checker, consulted only for heritage-member lookups. */
|
||||
checker: ts.TypeChecker
|
||||
/** The aggregate violation list, appended in place. */
|
||||
violations: string[]
|
||||
}
|
||||
|
||||
/** True when a statement carries the `export` modifier. */
|
||||
function isExported(stmt: ts.Statement): boolean {
|
||||
return ts.canHaveModifiers(stmt) && (ts.getModifiers(stmt)?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false)
|
||||
}
|
||||
|
||||
/** True for a class member a consumer cannot reach: `private`/`protected`/`#name`. */
|
||||
function isNonPublic(member: ts.ClassElement): boolean {
|
||||
const mods = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined
|
||||
return (mods?.some(m => m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword) ?? false)
|
||||
|| ('name' in member && ts.isPrivateIdentifier(member.name))
|
||||
}
|
||||
|
||||
/** True when a class member carries the `static` modifier. */
|
||||
function isStatic(member: ts.ClassElement): boolean {
|
||||
const mods = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined
|
||||
return mods?.some(m => m.kind === ts.SyntaxKind.StaticKeyword) ?? false
|
||||
}
|
||||
|
||||
/** The `this`-receiver exemption every function-like check shares. */
|
||||
function thisReceiver(p: ts.ParameterDeclaration): boolean {
|
||||
return ts.isIdentifier(p.name) && p.name.text === 'this'
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a member name exists on any `extends`/`implements` heritage type
|
||||
* of the class — the member implements or overrides a documented seam
|
||||
* declaration, which is the doc's one home (the IDE inherits it on hover).
|
||||
* Static members are looked up on the base CONSTRUCTOR type (only an
|
||||
* `extends` expression has one; an unresolvable or interface expression
|
||||
* yields no property and therefore no exemption).
|
||||
* @param cls - the class whose heritage to search.
|
||||
* @param name - the member name to look up.
|
||||
* @param staticSide - whether to search the constructor side instead of the instance side.
|
||||
* @param checker - the program's type checker.
|
||||
* @returns true when a heritage type declares the member.
|
||||
*/
|
||||
function inheritedMember(cls: ts.ClassDeclaration, name: string, staticSide: boolean, checker: ts.TypeChecker): boolean {
|
||||
for (const clause of cls.heritageClauses ?? []) {
|
||||
for (const t of clause.types) {
|
||||
const type = staticSide ? checker.getTypeAtLocation(t.expression) : checker.getTypeAtLocation(t)
|
||||
if (type.getProperty(name) !== undefined) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check description-prose presence for one labeled declaration: JSDoc must
|
||||
* exist and carry prose above its block tags.
|
||||
* @param where - the offender label violations open with.
|
||||
* @param raw - the declaration's raw JSDoc block ('' if none).
|
||||
* @param w - the walk state violations append to.
|
||||
*/
|
||||
function checkDescribed(where: string, raw: string, w: Walk): void {
|
||||
if (!raw) w.violations.push(`${where} has no JSDoc.`)
|
||||
else if (!parseJsDoc(raw).doc) w.violations.push(`${where} has no description prose above its block tags.`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the full function contract for one labeled function-like declaration:
|
||||
* description prose, `@param` per parameter, `@returns` on a non-void result.
|
||||
* @param where - the offender label violations open with.
|
||||
* @param raw - the declaration's raw JSDoc block ('' if none).
|
||||
* @param parameters - the declaration's parameter list.
|
||||
* @param returnType - the return type annotation, or undefined when inferred.
|
||||
* @param returnsWaived - suppress the `@returns`/annotation requirement (a
|
||||
* declarator-annotated const defers its return contract to the named type).
|
||||
* @param w - the walk state violations append to.
|
||||
*/
|
||||
function checkFunctionLike(
|
||||
where: string,
|
||||
raw: string,
|
||||
parameters: readonly ts.ParameterDeclaration[],
|
||||
returnType: ts.TypeNode | undefined,
|
||||
returnsWaived: boolean,
|
||||
w: Walk,
|
||||
): void {
|
||||
if (!raw) { w.violations.push(`${where} has no JSDoc.`); return }
|
||||
if (!parseJsDoc(raw).doc) w.violations.push(`${where} has no description prose above its block tags.`)
|
||||
const { params, returns } = parseTags(raw)
|
||||
checkParams(where, 'export', parameters, params, w.sf, thisReceiver, w.violations)
|
||||
if (!returnsWaived) checkReturns(where, returnType, returns, w.sf, w.violations)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check one exported class: class-level prose, the function contract on every
|
||||
* public method (overload implementations exempt), and description prose on
|
||||
* public properties and accessors (a get/set pair is covered by the getter's
|
||||
* doc). Members declared by a heritage type and the plugin-protocol statics
|
||||
* are exempt; constructors are not checked (framework-constructed plugins,
|
||||
* and the class doc owns the story).
|
||||
* @param cls - the exported class declaration.
|
||||
* @param name - the class's surface name (namespace-qualified).
|
||||
* @param w - the walk state violations append to.
|
||||
*/
|
||||
function checkClass(cls: ts.ClassDeclaration, name: string, w: Walk): void {
|
||||
checkDescribed(`exported class '${name}' (${pointer(w.rel, w.sf, cls)})`, rawJsDoc(w.text, cls), w)
|
||||
const overloadSigs = new Set<string>()
|
||||
const documentedGetters = new Set<string>()
|
||||
for (const m of cls.members) {
|
||||
if ('name' in m && ts.isComputedPropertyName(m.name)) continue
|
||||
if (ts.isMethodDeclaration(m) && !m.body) overloadSigs.add(m.name.getText(w.sf))
|
||||
if (ts.isGetAccessorDeclaration(m)) documentedGetters.add(m.name.getText(w.sf))
|
||||
}
|
||||
for (const m of cls.members) {
|
||||
if (isNonPublic(m) || ts.isConstructorDeclaration(m)) continue
|
||||
if (!('name' in m) || ts.isComputedPropertyName(m.name)) continue // computed/symbol members
|
||||
const mname = m.name.getText(w.sf)
|
||||
if (isStatic(m) && PROTOCOL_STATICS.has(mname)) continue // cordis plugin-protocol slot
|
||||
if (inheritedMember(cls, mname, isStatic(m), w.checker)) continue // the heritage declaration owns the doc
|
||||
if (ts.isMethodDeclaration(m)) {
|
||||
if (m.body && overloadSigs.has(mname)) continue // overload implementation: the signatures carry the docs
|
||||
checkFunctionLike(`exported class method '${name}.${mname}' (${pointer(w.rel, w.sf, m)})`, rawJsDoc(w.text, m), m.parameters, m.type, false, w)
|
||||
} else if (ts.isGetAccessorDeclaration(m) || ts.isPropertyDeclaration(m)) {
|
||||
const kind = ts.isPropertyDeclaration(m) ? 'property' : 'accessor'
|
||||
checkDescribed(`exported class ${kind} '${name}.${mname}' (${pointer(w.rel, w.sf, m)})`, rawJsDoc(w.text, m), w)
|
||||
} else if (ts.isSetAccessorDeclaration(m) && !documentedGetters.has(mname)) {
|
||||
checkDescribed(`exported class accessor '${name}.${mname}' (${pointer(w.rel, w.sf, m)})`, rawJsDoc(w.text, m), w)
|
||||
}
|
||||
// index signatures / static blocks: not named surface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check one exported declaration statement, dispatching on its kind.
|
||||
* @param stmt - the exported statement (export modifier or export-list target).
|
||||
* @param prefix - the namespace qualification for surface names ('' at top level).
|
||||
* @param overloadSigs - names in this scope declared as bodyless function overload signatures.
|
||||
* @param byName - this scope's named declarations (for namespace/sibling-merge lookups).
|
||||
* @param w - the walk state violations append to.
|
||||
*/
|
||||
function checkDecl(
|
||||
stmt: ts.Statement,
|
||||
prefix: string,
|
||||
overloadSigs: Set<string>,
|
||||
byName: Map<string, ts.Statement[]>,
|
||||
w: Walk,
|
||||
): void {
|
||||
const at = (n: ts.Node): string => ` (${pointer(w.rel, w.sf, n)})`
|
||||
if (ts.isFunctionDeclaration(stmt)) {
|
||||
const name = stmt.name?.text ?? 'default'
|
||||
if (prefix === '' && PROTOCOL_EXPORTS.has(name)) return // cordis plugin-protocol slot
|
||||
if (stmt.body && overloadSigs.has(name)) return // overload implementation: the signatures carry the docs
|
||||
checkFunctionLike(`exported function '${prefix}${name}'${at(stmt)}`, rawJsDoc(w.text, stmt),
|
||||
stmt.parameters, stmt.type, false, w)
|
||||
return
|
||||
}
|
||||
if (ts.isClassDeclaration(stmt)) {
|
||||
checkClass(stmt, `${prefix}${stmt.name?.text ?? 'default'}`, w)
|
||||
return
|
||||
}
|
||||
if (ts.isInterfaceDeclaration(stmt)) {
|
||||
checkDescribed(`exported interface '${prefix}${stmt.name.text}'${at(stmt)}`, rawJsDoc(w.text, stmt), w)
|
||||
return
|
||||
}
|
||||
if (ts.isTypeAliasDeclaration(stmt)) {
|
||||
checkDescribed(`exported type '${prefix}${stmt.name.text}'${at(stmt)}`, rawJsDoc(w.text, stmt), w)
|
||||
return
|
||||
}
|
||||
if (ts.isEnumDeclaration(stmt)) {
|
||||
checkDescribed(`exported enum '${prefix}${stmt.name.text}'${at(stmt)}`, rawJsDoc(w.text, stmt), w)
|
||||
return
|
||||
}
|
||||
if (ts.isVariableStatement(stmt)) {
|
||||
const raw = rawJsDoc(w.text, stmt) // JSDoc sits on the statement, not the declarator
|
||||
for (const d of stmt.declarationList.declarations) {
|
||||
const name = ts.isIdentifier(d.name) ? d.name.text : d.name.getText(w.sf)
|
||||
if (prefix === '' && PROTOCOL_EXPORTS.has(name)) continue // cordis plugin-protocol slot
|
||||
const where = `exported const '${prefix}${name}'${at(d)}`
|
||||
const init = d.initializer
|
||||
if (init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init))) {
|
||||
// A declarator type annotation (`const f: Handler = …`) hands the
|
||||
// return contract to the named type; the arrow's own annotation is
|
||||
// still checked when it is the only signature the reader has.
|
||||
checkFunctionLike(where, raw, init.parameters, init.type, init.type === undefined && d.type !== undefined, w)
|
||||
} else {
|
||||
checkDescribed(where, raw, w)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (ts.isModuleDeclaration(stmt) && ts.isIdentifier(stmt.name)) {
|
||||
// A namespace merging with a documented same-name sibling (the
|
||||
// Config-namespace idiom) needs no second doc block of its own.
|
||||
const siblings = (byName.get(stmt.name.text) ?? []).filter(s => s !== stmt)
|
||||
const merged = siblings.some(s => parseJsDoc(rawJsDoc(w.text, s)).doc !== '')
|
||||
if (!merged) checkDescribed(`exported namespace '${prefix}${stmt.name.text}'${at(stmt)}`, rawJsDoc(w.text, stmt), w)
|
||||
let body = stmt.body
|
||||
let nsPrefix = `${prefix}${stmt.name.text}.`
|
||||
while (body !== undefined && ts.isModuleDeclaration(body)) { // dotted `namespace A.B`
|
||||
nsPrefix += `${body.name.getText(w.sf)}.`
|
||||
body = body.body
|
||||
}
|
||||
if (body !== undefined && ts.isModuleBlock(body)) checkScope(body.statements, nsPrefix, w)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk one lexical scope (file top level or a namespace body): check every
|
||||
* exported declaration, resolving `export { … }` lists (no module specifier)
|
||||
* to their local declarations.
|
||||
* @param statements - the scope's statements.
|
||||
* @param prefix - the namespace qualification for surface names ('' at top level).
|
||||
* @param w - the walk state violations append to.
|
||||
*/
|
||||
function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk): void {
|
||||
const byName = new Map<string, ts.Statement[]>()
|
||||
const overloadSigs = new Set<string>()
|
||||
const add = (name: string, stmt: ts.Statement): void => {
|
||||
byName.set(name, [...(byName.get(name) ?? []), stmt])
|
||||
}
|
||||
for (const stmt of statements) {
|
||||
if (ts.isFunctionDeclaration(stmt)) {
|
||||
if (stmt.name) add(stmt.name.text, stmt)
|
||||
if (!stmt.body && stmt.name) overloadSigs.add(stmt.name.text)
|
||||
} else if (ts.isClassDeclaration(stmt) || ts.isInterfaceDeclaration(stmt)
|
||||
|| ts.isTypeAliasDeclaration(stmt) || ts.isEnumDeclaration(stmt)) {
|
||||
if (stmt.name) add(stmt.name.text, stmt)
|
||||
} else if (ts.isModuleDeclaration(stmt) && ts.isIdentifier(stmt.name)) {
|
||||
add(stmt.name.text, stmt)
|
||||
} else if (ts.isVariableStatement(stmt)) {
|
||||
for (const d of stmt.declarationList.declarations) {
|
||||
if (ts.isIdentifier(d.name)) add(d.name.text, stmt)
|
||||
}
|
||||
}
|
||||
}
|
||||
const checked = new Set<ts.Statement>()
|
||||
const check = (stmt: ts.Statement): void => {
|
||||
if (checked.has(stmt)) return
|
||||
checked.add(stmt)
|
||||
checkDecl(stmt, prefix, overloadSigs, byName, w)
|
||||
}
|
||||
for (const stmt of statements) {
|
||||
if (ts.isModuleDeclaration(stmt)
|
||||
&& (ts.isStringLiteral(stmt.name) || (stmt.flags & ts.NodeFlags.GlobalAugmentation) !== 0)) {
|
||||
continue // `declare module '…'` / `declare global` augmentation: not an export of this package
|
||||
}
|
||||
if (ts.isExportDeclaration(stmt)) {
|
||||
if (stmt.moduleSpecifier) continue // re-export: the defining module is walked on its own
|
||||
if (stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {
|
||||
for (const el of stmt.exportClause.elements) {
|
||||
for (const decl of byName.get((el.propertyName ?? el.name).text) ?? []) check(decl)
|
||||
// a name with no local declaration is an imported binding re-exported
|
||||
// without a specifier — its defining module is walked on its own
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) {
|
||||
if (ts.isIdentifier(stmt.expression)) {
|
||||
for (const decl of byName.get(stmt.expression.text) ?? []) check(decl)
|
||||
} else {
|
||||
checkDescribed(`default export (${pointer(w.rel, w.sf, stmt)})`, rawJsDoc(w.text, stmt), w)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (isExported(stmt)) check(stmt)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiler options for the walk's program. The real repo hands over its
|
||||
* tsconfig.base.json (whose `paths` map resolves cross-package imports to
|
||||
* source, so heritage-member lookups see seam types); a fixture root without
|
||||
* one gets bare defaults — fixtures are single-file and self-contained.
|
||||
* Emit-side options are stripped: the walk never emits or asks for
|
||||
* diagnostics, it only binds types on demand.
|
||||
* @param scanRoot - the root being scanned.
|
||||
* @returns compiler options for ts.createProgram.
|
||||
*/
|
||||
function loadCompilerOptions(scanRoot: string): ts.CompilerOptions {
|
||||
const cfgPath = resolve(scanRoot, 'tsconfig.base.json')
|
||||
if (!existsSync(cfgPath)) return { skipLibCheck: true }
|
||||
const cfg = ts.readConfigFile(cfgPath, ts.sys.readFile.bind(ts.sys)) as { config?: unknown }
|
||||
const parsed = ts.parseJsonConfigFileContent(cfg.config ?? {}, ts.sys, scanRoot)
|
||||
return {
|
||||
...parsed.options,
|
||||
noEmit: true,
|
||||
composite: false,
|
||||
declaration: false,
|
||||
declarationMap: false,
|
||||
sourceMap: false,
|
||||
incremental: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk every non-vendored package source file and collect JSDoc-completeness
|
||||
* violations for its module-level exports. Returns findings instead of
|
||||
* throwing so tests assert on the list; the CLI entry turns a non-empty list
|
||||
* into exit 1.
|
||||
* @param scanRoot - the repo root to scan; tests pass a fixture dir.
|
||||
* @returns every violation, in file order, one human-readable line each.
|
||||
*/
|
||||
export function collectExportJsdocViolations(scanRoot: string = root): string[] {
|
||||
const violations: string[] = []
|
||||
const rels = globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).sort()
|
||||
const program = ts.createProgram(rels.map(rel => resolve(scanRoot, rel)), loadCompilerOptions(scanRoot))
|
||||
const checker = program.getTypeChecker()
|
||||
for (const rel of rels) {
|
||||
const sf = program.getSourceFile(resolve(scanRoot, rel))
|
||||
if (!sf) continue // program root files always resolve; guard for narrowing
|
||||
checkScope(sf.statements, '', { rel, sf, text: sf.text, checker, violations })
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
/** CLI entry: list every violation and exit 1, or confirm a clean surface. */
|
||||
function main(): void {
|
||||
const violations = collectExportJsdocViolations()
|
||||
if (violations.length === 0) {
|
||||
console.log('verify-export-jsdoc: every exported name on the package surface is documented.')
|
||||
return
|
||||
}
|
||||
console.error(`verify-export-jsdoc: ${violations.length} JSDoc completeness violation(s) (see AGENTS.md):`)
|
||||
for (const v of violations) console.error(` ${v}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Run only when invoked as a script, not when imported by a test.
|
||||
if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
|
||||
main()
|
||||
}
|
||||
Reference in New Issue
Block a user