From 9e9228270adc89c7f8e5adf773af6c036fcee53b Mon Sep 17 00:00:00 2001 From: lintianle Date: Thu, 16 Jul 2026 22:07:06 +0800 Subject: [PATCH] scripts: extract shared cordis-walk and md-fences helpers (jscpd gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- scripts/cordis-walk.ts | 94 ++++++++++++++++++++++++++++++++++ scripts/doc-typecheck.ts | 43 +++++----------- scripts/gen-cordis-catalog.ts | 91 +++++++++++--------------------- scripts/gen-website-api.ts | 78 ++++++++++------------------ scripts/md-fences.ts | 55 ++++++++++++++++++++ scripts/verify-website-yaml.ts | 30 ++--------- 6 files changed, 223 insertions(+), 168 deletions(-) create mode 100644 scripts/cordis-walk.ts create mode 100644 scripts/md-fences.ts diff --git a/scripts/cordis-walk.ts b/scripts/cordis-walk.ts new file mode 100644 index 0000000000..44e87b2a21 --- /dev/null +++ b/scripts/cordis-walk.ts @@ -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. → type name` map declared by a merge's `interface Context`. */ +function contextKeyMap(body: ts.ModuleBlock, sf: ts.SourceFile): Map { + const keyToType = new Map() + 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.` 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.` 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 +} diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index ceecbda5e7..0ace1072d6 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -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 = { + '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. */ diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index ebbdcf1fbf..2c31d95f52 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -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() - 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 diff --git a/scripts/gen-website-api.ts b/scripts/gen-website-api.ts index cb7ddfd8da..bd0da34fde 100644 --- a/scripts/gen-website-api.ts +++ b/scripts/gen-website-api.ts @@ -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() 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() - 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() 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)) diff --git a/scripts/md-fences.ts b/scripts/md-fences.ts new file mode 100644 index 0000000000..8c84b27457 --- /dev/null +++ b/scripts/md-fences.ts @@ -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 { + /** 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(absPath: string, classify: (info: string) => K | null): Fence[] { + const lines = readFileSync(absPath, 'utf8').split('\n') + const blocks: Fence[] = [] + 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 +} diff --git a/scripts/verify-website-yaml.ts b/scripts/verify-website-yaml.ts index 0f46b6a5aa..6c086ab645 100644 --- a/scripts/verify-website-yaml.ts +++ b/scripts/verify-website-yaml.ts @@ -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//` and `vendor/`. */