scripts: extract shared cordis-walk and md-fences helpers (jscpd gate)
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.
This commit is contained in:
94
scripts/cordis-walk.ts
Normal file
94
scripts/cordis-walk.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { execFileSync } from 'node:child_process'
|
||||
import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join, relative, resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { extractFences } from './md-fences.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
@@ -27,39 +28,21 @@ interface Block {
|
||||
code: string
|
||||
}
|
||||
|
||||
/** The info-string → kind table this gate tracks. */
|
||||
const KIND_BY_INFO: Record<string, BlockKind> = {
|
||||
'ts': 'check',
|
||||
'ts ignore-check': 'ignore',
|
||||
'ts type-equiv': 'type-equiv',
|
||||
'ts cordis-catalog': 'cordis-catalog',
|
||||
'ts persistence-catalog': 'persistence-catalog',
|
||||
'ts config-catalog': 'config-catalog',
|
||||
}
|
||||
|
||||
/** Extract every recognized TypeScript fence from one Markdown file. */
|
||||
function extractBlocks(absPath: string): Block[] {
|
||||
const text = readFileSync(absPath, 'utf8')
|
||||
const lines = text.split('\n')
|
||||
const file = relative(root, absPath)
|
||||
const blocks: Block[] = []
|
||||
let open: { line: number; kind: BlockKind; body: string[] } | null = null
|
||||
|
||||
lines.forEach((raw, i) => {
|
||||
const fence = /^```(\s*)(\S.*)?$/.exec(raw)
|
||||
if (!fence) {
|
||||
if (open) open.body.push(raw)
|
||||
return
|
||||
}
|
||||
if (open) {
|
||||
// closing fence
|
||||
blocks.push({ file, line: open.line, kind: open.kind, code: open.body.join('\n') })
|
||||
open = null
|
||||
return
|
||||
}
|
||||
// Ignore non-TypeScript fences.
|
||||
const info = (fence[2] ?? '').trim()
|
||||
const kind: BlockKind | null =
|
||||
info === 'ts' ? 'check'
|
||||
: info === 'ts ignore-check' ? 'ignore'
|
||||
: info === 'ts type-equiv' ? 'type-equiv'
|
||||
: info === 'ts cordis-catalog' ? 'cordis-catalog'
|
||||
: info === 'ts persistence-catalog' ? 'persistence-catalog'
|
||||
: info === 'ts config-catalog' ? 'config-catalog'
|
||||
: null
|
||||
if (kind) open = { line: i + 1, kind, body: [] }
|
||||
})
|
||||
return blocks
|
||||
return extractFences(absPath, info => KIND_BY_INFO[info] ?? null)
|
||||
.map(f => ({ file, line: f.line, kind: f.kind, code: f.code }))
|
||||
}
|
||||
|
||||
/** Reuse the repo typecheck graph references from a temp project one directory below root. */
|
||||
|
||||
@@ -9,6 +9,7 @@ import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts'
|
||||
import { cordisModuleBody, eventMembers, serviceClasses } from './cordis-walk.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT_EVENTS = 'docs/cordis-catalog/events.md'
|
||||
@@ -102,15 +103,8 @@ interface InheritedEntry {
|
||||
source: string
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
if (ts.isModuleDeclaration(stmt) && ts.isStringLiteral(stmt.name) && stmt.name.text === 'cordis') {
|
||||
if (stmt.body && ts.isModuleBlock(stmt.body)) return stmt.body
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
// cordisModuleBody / eventMembers / serviceClasses live in cordis-walk.ts,
|
||||
// shared with gen-website-api.ts — one walk, two renderers.
|
||||
|
||||
/** The signature text of a method-signature member (everything but a body). */
|
||||
function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.SourceFile): string {
|
||||
@@ -134,38 +128,33 @@ export function collectEvents(scanRoot: string = root): EventEntry[] {
|
||||
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
|
||||
const body = cordisModuleBody(sf)
|
||||
if (!body) continue
|
||||
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)
|
||||
const signature = memberSignature(member, sf)
|
||||
const raw = rawJsDoc(text, member)
|
||||
const { doc, mode } = parseJsDoc(raw)
|
||||
const src = pointer(rel, sf, member)
|
||||
const where = `event '${name}' (${src})`
|
||||
if (!mode) {
|
||||
violations.push(`${where} is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`)
|
||||
}
|
||||
// Conclusive structural check: a trailing `next: () => …` parameter is a
|
||||
// waterfall. (emit vs parallel vs serial is not structurally
|
||||
// distinguishable, so it is trusted from the tag.)
|
||||
const last = member.parameters.at(-1)
|
||||
const hasNext = !!last && last.name.getText(sf) === 'next'
|
||||
if (mode && hasNext && mode !== 'waterfall') {
|
||||
violations.push(`${where} has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`)
|
||||
}
|
||||
if (mode && !hasNext && mode === 'waterfall') {
|
||||
violations.push(`${where} is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`)
|
||||
}
|
||||
if (!doc) violations.push(`${where} has no description prose. Say what happened / what a listener may do, above the block tags.`)
|
||||
// Payload parameters need a non-empty @param. The `this` receiver is not
|
||||
// payload, and a waterfall's trailing `next` is covered by its mode.
|
||||
const { params } = parseTags(raw)
|
||||
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 })
|
||||
for (const { name, member } of eventMembers(body, sf)) {
|
||||
const signature = memberSignature(member, sf)
|
||||
const raw = rawJsDoc(text, member)
|
||||
const { doc, mode } = parseJsDoc(raw)
|
||||
const src = pointer(rel, sf, member)
|
||||
const where = `event '${name}' (${src})`
|
||||
if (!mode) {
|
||||
violations.push(`${where} is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`)
|
||||
}
|
||||
// Conclusive structural check: a trailing `next: () => …` parameter is a
|
||||
// waterfall. (emit vs parallel vs serial is not structurally
|
||||
// distinguishable, so it is trusted from the tag.)
|
||||
const last = member.parameters.at(-1)
|
||||
const hasNext = !!last && last.name.getText(sf) === 'next'
|
||||
if (mode && hasNext && mode !== 'waterfall') {
|
||||
violations.push(`${where} has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`)
|
||||
}
|
||||
if (mode && !hasNext && mode === 'waterfall') {
|
||||
violations.push(`${where} is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`)
|
||||
}
|
||||
if (!doc) violations.push(`${where} has no description prose. Say what happened / what a listener may do, above the block tags.`)
|
||||
// Payload parameters need a non-empty @param. The `this` receiver is not
|
||||
// payload, and a waterfall's trailing `next` is covered by its mode.
|
||||
const { params } = parseTags(raw)
|
||||
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('gen-cordis-catalog', violations)
|
||||
@@ -188,26 +177,8 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
|
||||
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
|
||||
const body = cordisModuleBody(sf)
|
||||
if (!body) continue
|
||||
// The ctx key → type mapping(s) declared in this file's interface Context.
|
||||
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
|
||||
const key = member.name.getText(sf)
|
||||
keyToType.set(key, member.type.getText(sf))
|
||||
}
|
||||
}
|
||||
if (keyToType.size === 0) continue
|
||||
// Find each service class declared in the same file and emit an entry.
|
||||
for (const [key, type] of keyToType) {
|
||||
const cls = sf.statements.find(
|
||||
(s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === type,
|
||||
)
|
||||
if (!cls) continue // a Pick-mixin member (e.g. timer helpers), not a class here
|
||||
const abstract = cls.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false
|
||||
const clsDoc = parseJsDoc(rawJsDoc(text, cls)).doc
|
||||
if (!clsDoc) violations.push(`service ctx.${key} (${pointer(rel, sf, cls)}): class ${type} has no JSDoc.`)
|
||||
// Resolve each ctx key to its service class (shared walk) and emit an entry.
|
||||
for (const { key, type, cls, abstract, doc: clsDoc } of serviceClasses(body, sf, rel, violations)) {
|
||||
const methods: string[] = []
|
||||
for (const member of cls.members) {
|
||||
if (!ts.isMethodDeclaration(member)) continue
|
||||
|
||||
@@ -35,6 +35,7 @@ import { globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts'
|
||||
import { cordisModuleBody, eventMembers, serviceClasses } from './cordis-walk.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
@@ -157,17 +158,8 @@ function load(rel: string): { sf: ts.SourceFile; text: string } {
|
||||
sfCache.set(rel, entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
/** The body of a `declare module './context.ts'` / `declare module 'cordis'`
|
||||
* block, or null. */
|
||||
function moduleBody(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 !== './context.ts' && stmt.name.text !== 'cordis') continue
|
||||
if (stmt.body && ts.isModuleBlock(stmt.body)) return stmt.body
|
||||
}
|
||||
return null
|
||||
}
|
||||
// The module-merge walk (cordisModuleBody / eventMembers / serviceClasses) is
|
||||
// shared with gen-cordis-catalog.ts via cordis-walk.ts.
|
||||
|
||||
/** Signature text of a member: full text minus body/initializer, whitespace
|
||||
* collapsed, trailing semicolon stripped. */
|
||||
@@ -306,7 +298,7 @@ function heritageMembers(
|
||||
* `Pick<…>` heritage resolved to the picked class members. */
|
||||
function contextMergeMembers(rel: string, violations: string[]): MemberDoc[] {
|
||||
const { sf } = load(rel)
|
||||
const body = moduleBody(sf)
|
||||
const body = cordisModuleBody(sf)
|
||||
if (!body) throw new Error(`gen-website-api: ${rel} has no context module merge`)
|
||||
const groups = new Map<string, (ts.MethodSignature | ts.PropertySignature | ts.MethodDeclaration)[]>()
|
||||
for (const stmt of body.statements) {
|
||||
@@ -450,26 +442,13 @@ function collectHarnessServices(violations: string[]): HarnessService[] {
|
||||
for (const rel of globSync('packages/*/*/src/index.ts', { cwd: root }).sort()) {
|
||||
const { sf, text } = load(rel)
|
||||
if (!text.includes('interface Context')) continue
|
||||
const body = moduleBody(sf)
|
||||
const body = cordisModuleBody(sf)
|
||||
if (!body) continue
|
||||
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))
|
||||
}
|
||||
}
|
||||
const pkgJson = rel.replace(/src\/index\.ts$/, 'package.json')
|
||||
const pkg = (JSON.parse(readFileSync(resolve(root, pkgJson), 'utf8')) as { name: string }).name
|
||||
for (const [key, type] of keyToType) {
|
||||
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 clsDoc = parseJsDoc(rawJsDoc(text, cls)).doc
|
||||
if (!clsDoc) violations.push(`service ctx.${key} (${pointer(rel, sf, cls)}): class ${type} has no JSDoc.`)
|
||||
// Manifest shape is repo-owned; `name` is the one field read here.
|
||||
const manifest = JSON.parse(readFileSync(resolve(root, pkgJson), 'utf8')) as { name: string }
|
||||
const pkg = manifest.name
|
||||
for (const { key, type, cls, abstract, doc: clsDoc } of serviceClasses(body, sf, rel, violations)) {
|
||||
const groups = new Map<string, (ts.MethodDeclaration | ts.PropertyDeclaration | ts.GetAccessorDeclaration)[]>()
|
||||
for (const member of cls.members) {
|
||||
// Public properties are API too: ctx.codeRuntime.language/isolation
|
||||
@@ -507,30 +486,25 @@ function collectHarnessEvents(violations: string[]): HarnessEvent[] {
|
||||
for (const rel of globSync('packages/*/*/src/*.ts', { cwd: root }).sort()) {
|
||||
const { sf, text } = load(rel)
|
||||
if (!text.includes('interface Events')) continue
|
||||
const body = moduleBody(sf)
|
||||
const body = cordisModuleBody(sf)
|
||||
if (!body) continue
|
||||
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)
|
||||
const raw = rawJsDoc(text, member)
|
||||
const { doc, mode } = parseJsDoc(raw)
|
||||
if (!mode) violations.push(`event '${name}' (${pointer(rel, sf, member)}) is missing @mode.`)
|
||||
if (!doc) violations.push(`event '${name}' (${pointer(rel, sf, member)}) has no JSDoc prose.`)
|
||||
const { params: tags } = parseTags(raw)
|
||||
const last = member.parameters.at(-1)
|
||||
const hasNext = !!last && last.name.getText(sf) === 'next'
|
||||
checkParams(`event '${name}' (${pointer(rel, sf, member)})`, 'website-api', member.parameters, tags, sf,
|
||||
p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations)
|
||||
const params: { name: string; text: string }[] = []
|
||||
for (const p of member.parameters) {
|
||||
const pname = p.name.getText(sf)
|
||||
const tag = tags.get(pname)
|
||||
if (tag) params.push({ name: pname, text: tag })
|
||||
}
|
||||
events.push({ name, scope: name.split('/')[0] ?? name, mode, signature: signatureOf(member, sf), doc, params, source: pointer(rel, sf, member) })
|
||||
for (const { name, member } of eventMembers(body, sf)) {
|
||||
const raw = rawJsDoc(text, member)
|
||||
const { doc, mode } = parseJsDoc(raw)
|
||||
if (!mode) violations.push(`event '${name}' (${pointer(rel, sf, member)}) is missing @mode.`)
|
||||
if (!doc) violations.push(`event '${name}' (${pointer(rel, sf, member)}) has no JSDoc prose.`)
|
||||
const { params: tags } = parseTags(raw)
|
||||
const last = member.parameters.at(-1)
|
||||
const hasNext = !!last && last.name.getText(sf) === 'next'
|
||||
checkParams(`event '${name}' (${pointer(rel, sf, member)})`, 'website-api', member.parameters, tags, sf,
|
||||
p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations)
|
||||
const params: { name: string; text: string }[] = []
|
||||
for (const p of member.parameters) {
|
||||
const pname = p.name.getText(sf)
|
||||
const tag = tags.get(pname)
|
||||
if (tag) params.push({ name: pname, text: tag })
|
||||
}
|
||||
events.push({ name, scope: name.split('/')[0] ?? name, mode, signature: signatureOf(member, sf), doc, params, source: pointer(rel, sf, member) })
|
||||
}
|
||||
}
|
||||
return events.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
55
scripts/md-fences.ts
Normal file
55
scripts/md-fences.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Shared fenced-code-block extractor for the Markdown doc gates
|
||||
* (`doc-typecheck.ts`, `verify-website-yaml.ts`). One scanner, per-gate
|
||||
* classification: each gate maps a fence info string (` ```ts `,
|
||||
* ` ```yaml ignore-check `, …) to its own kind tag and receives every
|
||||
* classified block with its 1-based opening-fence line.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
/** One extracted fenced block, classified by the caller's `classify`. */
|
||||
export interface Fence<K> {
|
||||
/** 1-based line of the opening fence. */
|
||||
line: number
|
||||
kind: K
|
||||
code: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract every fenced block of `absPath` whose info string `classify` maps
|
||||
* to a kind. Blocks classified `null` are skipped (their bodies are still
|
||||
* consumed, so an unrelated fence can never leak into a tracked one).
|
||||
*
|
||||
* @param absPath — absolute path of the Markdown file.
|
||||
* @param classify — info string (trimmed, e.g. `ts ignore-check`) → kind, or
|
||||
* null for fences this gate does not track.
|
||||
* @returns the classified blocks in document order.
|
||||
*/
|
||||
export function extractFences<K>(absPath: string, classify: (info: string) => K | null): Fence<K>[] {
|
||||
const lines = readFileSync(absPath, 'utf8').split('\n')
|
||||
const blocks: Fence<K>[] = []
|
||||
let open: { line: number; kind: K; body: string[] } | null = null
|
||||
let skipping = false
|
||||
|
||||
lines.forEach((raw, i) => {
|
||||
const fence = /^```(\s*)(\S.*)?$/.exec(raw)
|
||||
if (!fence) {
|
||||
if (open) open.body.push(raw)
|
||||
return
|
||||
}
|
||||
if (open) {
|
||||
blocks.push({ line: open.line, kind: open.kind, code: open.body.join('\n') })
|
||||
open = null
|
||||
return
|
||||
}
|
||||
if (skipping) {
|
||||
skipping = false
|
||||
return
|
||||
}
|
||||
const kind = classify((fence[2] ?? '').trim())
|
||||
if (kind !== null) open = { line: i + 1, kind, body: [] }
|
||||
else skipping = true
|
||||
})
|
||||
return blocks
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import { resolve } from 'node:path'
|
||||
import * as yaml from 'js-yaml'
|
||||
import ts from 'typescript'
|
||||
import { collectConfigCatalog, type CatalogEntry } from './gen-config-catalog.ts'
|
||||
import { extractFences } from './md-fences.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
@@ -80,32 +81,9 @@ interface Block {
|
||||
|
||||
/** Extract every ```yaml / ```yaml ignore-check block from one Markdown file. */
|
||||
function extractBlocks(file: string): Block[] {
|
||||
const text = readFileSync(resolve(root, file), 'utf8')
|
||||
const lines = text.split('\n')
|
||||
const blocks: Block[] = []
|
||||
let open: { line: number; kind: Block['kind']; body: string[] } | null = null
|
||||
|
||||
lines.forEach((raw, i) => {
|
||||
const fence = /^```(\s*)(\S.*)?$/.exec(raw)
|
||||
if (!fence) {
|
||||
if (open) open.body.push(raw)
|
||||
return
|
||||
}
|
||||
if (open) {
|
||||
// closing fence
|
||||
blocks.push({ file, line: open.line, kind: open.kind, code: open.body.join('\n') })
|
||||
open = null
|
||||
return
|
||||
}
|
||||
// opening fence — only yaml blocks participate
|
||||
const info = (fence[2] ?? '').trim()
|
||||
const kind: Block['kind'] | null =
|
||||
info === 'yaml' ? 'check'
|
||||
: info === 'yaml ignore-check' ? 'ignore'
|
||||
: null
|
||||
if (kind) open = { line: i + 1, kind, body: [] }
|
||||
})
|
||||
return blocks
|
||||
return extractFences(resolve(root, file), info =>
|
||||
info === 'yaml' ? 'check' : info === 'yaml ignore-check' ? 'ignore' : null)
|
||||
.map(f => ({ file, line: f.line, kind: f.kind, code: f.code }))
|
||||
}
|
||||
|
||||
/** Every workspace package name: `packages/<group>/<pkg>` and `vendor/<pkg>`. */
|
||||
|
||||
Reference in New Issue
Block a user