Master's new duplication gate flagged 4 clones, all from the website scripts reusing existing gate patterns by copy. Extracted the shared halves instead of ignoring: - scripts/cordis-walk.ts — cordisModuleBody / eventMembers / serviceClasses, one AST walk consumed by both gen-cordis-catalog and gen-website-api (the two renderers must agree on WHAT exists; now they structurally do). - scripts/md-fences.ts — fence extractor with per-gate info-string classification, consumed by doc-typecheck and verify-website-yaml. Behavior-neutral: regenerated catalogs and website pages are byte-identical (--check green), doc-typecheck/yaml block counts unchanged. jscpd now reports 0 clones; full pre-push run 34/34 green.
95 lines
3.9 KiB
TypeScript
95 lines
3.9 KiB
TypeScript
/**
|
|
* Shared AST walkers for the cordis documentation generators
|
|
* (`gen-cordis-catalog.ts`, `gen-website-api.ts`): locating the cordis module
|
|
* merge in a source file, enumerating its `interface Events` members, and
|
|
* resolving the `interface Context` service keys to their service classes.
|
|
* One walk, two renderers — the catalog and the website page carry different
|
|
* prose but must agree on WHAT exists.
|
|
*/
|
|
|
|
import ts from 'typescript'
|
|
import { parseJsDoc, pointer, rawJsDoc } from './jsdoc.ts'
|
|
|
|
/** The body of the cordis module merge in `sf`: `declare module 'cordis'`
|
|
* (harness packages) or `declare module './context.ts'` (vendor core), or
|
|
* null when the file has neither. */
|
|
export function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
|
|
for (const stmt of sf.statements) {
|
|
if (!ts.isModuleDeclaration(stmt) || !ts.isStringLiteral(stmt.name)) continue
|
|
if (stmt.name.text !== 'cordis' && stmt.name.text !== './context.ts') continue
|
|
if (stmt.body && ts.isModuleBlock(stmt.body)) return stmt.body
|
|
}
|
|
return null
|
|
}
|
|
|
|
/** Every `interface Events` method member of a cordis module merge, with the
|
|
* event name resolved from its (possibly string-literal) property name. */
|
|
export function eventMembers(body: ts.ModuleBlock, sf: ts.SourceFile): { name: string; member: ts.MethodSignature }[] {
|
|
const out: { name: string; member: ts.MethodSignature }[] = []
|
|
for (const stmt of body.statements) {
|
|
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Events') continue
|
|
for (const member of stmt.members) {
|
|
if (!ts.isMethodSignature(member)) continue
|
|
const name = ts.isStringLiteral(member.name) ? member.name.text : member.name.getText(sf)
|
|
out.push({ name, member })
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** The `ctx.<key> → type name` map declared by a merge's `interface Context`. */
|
|
function contextKeyMap(body: ts.ModuleBlock, sf: ts.SourceFile): Map<string, string> {
|
|
const keyToType = new Map<string, string>()
|
|
for (const stmt of body.statements) {
|
|
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Context') continue
|
|
for (const member of stmt.members) {
|
|
if (!ts.isPropertySignature(member) || !member.type) continue
|
|
keyToType.set(member.name.getText(sf), member.type.getText(sf))
|
|
}
|
|
}
|
|
return keyToType
|
|
}
|
|
|
|
/** One `ctx.<key>` service class resolved from a Context merge. */
|
|
export interface ServiceClass {
|
|
key: string
|
|
type: string
|
|
cls: ts.ClassDeclaration
|
|
abstract: boolean
|
|
/** Class-level JSDoc prose (empty string when missing — also reported). */
|
|
doc: string
|
|
}
|
|
|
|
/**
|
|
* Resolve each `ctx.<key>` of a merge to the service class declared in the
|
|
* same file. A key whose type is not a class here (a Pick-mixin member, e.g.
|
|
* timer helpers) is skipped. A class without JSDoc prose is reported into
|
|
* `violations` (named `where` by the caller's gate).
|
|
*
|
|
* @param body — the cordis module merge body.
|
|
* @param sf — the source file containing the merge.
|
|
* @param rel — repo-relative path of `sf`, for violation pointers.
|
|
* @param violations — sink for JSDoc-completeness violations.
|
|
* @returns the resolved service classes, in Context-declaration order.
|
|
*/
|
|
export function serviceClasses(
|
|
body: ts.ModuleBlock,
|
|
sf: ts.SourceFile,
|
|
rel: string,
|
|
violations: string[],
|
|
): ServiceClass[] {
|
|
const text = sf.getFullText()
|
|
const out: ServiceClass[] = []
|
|
for (const [key, type] of contextKeyMap(body, sf)) {
|
|
const cls = sf.statements.find(
|
|
(s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === type,
|
|
)
|
|
if (!cls) continue // a Pick-mixin member, not a class here
|
|
const abstract = cls.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false
|
|
const doc = parseJsDoc(rawJsDoc(text, cls)).doc
|
|
if (!doc) violations.push(`service ctx.${key} (${pointer(rel, sf, cls)}): class ${type} has no JSDoc.`)
|
|
out.push({ key, type, cls, abstract, doc })
|
|
}
|
|
return out
|
|
}
|