Merge remote-tracking branch 'origin/master' into codex/skill-system
This commit is contained in:
@@ -105,11 +105,26 @@ const dshBinPackageFiles = [
|
||||
'src',
|
||||
] as const
|
||||
|
||||
// Packages that ship a worker-thread entry as a sibling runtime bundle
|
||||
// (lib/worker.js, its own tsdown entry): the bootstrap is loaded via
|
||||
// `new Worker(new URL('./worker.js', import.meta.url))`, so it cannot live
|
||||
// inside the index bundle and must be published alongside it.
|
||||
const workerEntryPackages = new Set(['@deepseek-ai/dsh-code-runtime-worker'])
|
||||
|
||||
const dshWorkerPackageFiles = [
|
||||
'lib/index.js',
|
||||
'lib/worker.js',
|
||||
'lib/types/**/*.d.ts',
|
||||
'lib/types/**/*.d.ts.map',
|
||||
'src',
|
||||
] as const
|
||||
|
||||
function sameStringList(actual: readonly string[] | undefined, expected: readonly string[]): boolean {
|
||||
return !!actual && actual.length === expected.length && actual.every((value, index) => value === expected[index])
|
||||
}
|
||||
|
||||
function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] {
|
||||
if (manifest.name && workerEntryPackages.has(manifest.name)) return dshWorkerPackageFiles
|
||||
return manifest.bin ? dshBinPackageFiles : dshPackageFiles
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"AGENTS.md": 1575,
|
||||
"AGENTS.md": 1802,
|
||||
"docs/AGENTS.md": 1315,
|
||||
"docs/architecture.md": 1630,
|
||||
"docs/architecture.md": 1640,
|
||||
"docs/cordis-primer.md": 550,
|
||||
"docs/defensive-patterns.md": 550,
|
||||
"docs/testing.md": 800,
|
||||
"examples/AGENTS.md": 610,
|
||||
"examples/AGENTS.md": 653,
|
||||
"packages/AGENTS.md": 450,
|
||||
"packages/README.md": 605
|
||||
"packages/README.md": 660
|
||||
}
|
||||
|
||||
@@ -9,23 +9,24 @@
|
||||
* opts out with an explicit ` ```ts ignore-check ` info string — the opt-out
|
||||
* is visible in the source, and this script reports the ratio so the escape
|
||||
* hatch can't quietly become the norm. A third info string,
|
||||
* doc-typecheck.ts recognizes three more fence variants and skips all three (each
|
||||
* doc-typecheck.ts recognizes four more fence variants and skips all four (each
|
||||
* is a separately-checked category, not an unchecked sketch, so none counts in
|
||||
* the opt-out ratio): ` ```ts type-equiv ` is a verbatim source-type paste that
|
||||
* `scripts/verify-type-equiv.ts` drift-checks, ` ```ts cordis-catalog ` is a
|
||||
* generated event/service signature fragment in the cordis catalog (a bare
|
||||
* signature is not standalone-compilable; the catalog is generated and frozen by
|
||||
* `scripts/gen-cordis-catalog.ts` + its `--check` freshness gate), and
|
||||
* `scripts/gen-cordis-catalog.ts` + its `--check` freshness gate),
|
||||
* ` ```ts persistence-catalog ` is a generated log-event payload fragment in the
|
||||
* persistence catalog (same reasoning, frozen by `scripts/gen-persistence-catalog.ts`).
|
||||
* persistence catalog (same reasoning, frozen by `scripts/gen-persistence-catalog.ts`),
|
||||
* and ` ```ts config-catalog ` is a generated verbatim config declaration in the
|
||||
* plugin config catalog (same reasoning, frozen by `scripts/gen-config-catalog.ts`).
|
||||
*
|
||||
* Run: `tsx scripts/doc-typecheck.ts`.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join, relative, resolve } from 'node:path'
|
||||
import { glob } from 'node:fs/promises'
|
||||
import ts from 'typescript'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
@@ -49,8 +50,12 @@ const root = resolve(import.meta.dirname, '..')
|
||||
* log-event payload fragment in the persistence catalog. Same treatment for
|
||||
* the same reason; frozen by `scripts/gen-persistence-catalog.ts` + its
|
||||
* `--check` freshness gate.
|
||||
* - `config-catalog` (` ```ts config-catalog `) — a generated verbatim config
|
||||
* declaration in the plugin config catalog (a lone declaration referencing
|
||||
* imported types does not stand alone). Same treatment for the same reason;
|
||||
* frozen by `scripts/gen-config-catalog.ts` + its `--check` freshness gate.
|
||||
*/
|
||||
type BlockKind = 'check' | 'ignore' | 'type-equiv' | 'cordis-catalog' | 'persistence-catalog'
|
||||
type BlockKind = 'check' | 'ignore' | 'type-equiv' | 'cordis-catalog' | 'persistence-catalog' | 'config-catalog'
|
||||
|
||||
/** One extracted code block. */
|
||||
interface Block {
|
||||
@@ -62,7 +67,7 @@ interface Block {
|
||||
}
|
||||
|
||||
/** Extract every ts / ts ignore-check / ts type-equiv / ts cordis-catalog /
|
||||
* ts persistence-catalog block from one Markdown file. */
|
||||
* ts persistence-catalog / ts config-catalog block from one Markdown file. */
|
||||
function extractBlocks(absPath: string): Block[] {
|
||||
const text = readFileSync(absPath, 'utf8')
|
||||
const lines = text.split('\n')
|
||||
@@ -90,7 +95,8 @@ function extractBlocks(absPath: string): Block[] {
|
||||
: info === 'ts type-equiv' ? 'type-equiv'
|
||||
: info === 'ts cordis-catalog' ? 'cordis-catalog'
|
||||
: info === 'ts persistence-catalog' ? 'persistence-catalog'
|
||||
: null
|
||||
: info === 'ts config-catalog' ? 'config-catalog'
|
||||
: null
|
||||
if (kind) open = { line: i + 1, kind, body: [] }
|
||||
})
|
||||
return blocks
|
||||
@@ -132,7 +138,7 @@ const markdownGlobs = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages
|
||||
|
||||
const files: string[] = []
|
||||
for (const pattern of markdownGlobs) {
|
||||
for await (const match of glob(pattern, { cwd: root })) files.push(resolve(root, match))
|
||||
for (const match of globSync(pattern, { cwd: root })) files.push(resolve(root, match))
|
||||
}
|
||||
files.sort()
|
||||
|
||||
|
||||
929
scripts/gen-config-catalog.ts
Normal file
929
scripts/gen-config-catalog.ts
Normal file
@@ -0,0 +1,929 @@
|
||||
/**
|
||||
* Generate (and verify) the plugin config catalog in docs/config-catalog.md.
|
||||
*
|
||||
* The page is the DEPLOYMENT-axis reference: for every harness package a
|
||||
* `cordis.yml` entry can load, the exact config surface its `apply` function or
|
||||
* service constructor receives — pasted VERBATIM from source (the `export
|
||||
* interface Config` declaration with its JSDoc), plus resolved links for every
|
||||
* type the declaration references. It complements the wiring-axis cordis
|
||||
* catalogs (events + services, what a plugin AUTHOR listens to and calls) the
|
||||
* same way the tool catalog complements them for the model-facing axis.
|
||||
*
|
||||
* The catalog is FULLY GENERATED from source — never hand-edit it. Like the
|
||||
* cordis catalog (and unlike the tool catalog, which must boot plugins), this
|
||||
* is a pure-AST pass: every config type is a static declaration and every
|
||||
* schemastery schema is a static `z.object`/`z.intersect` literal, so
|
||||
* generation cannot drift and a regenerate-and-diff freshness check (`--check`)
|
||||
* gates staleness. Because generation enumerates every package under
|
||||
* `packages/<group>/<pkg>`, a brand-new plugin cannot be silently
|
||||
* undocumented: it must classify as configurable, config-free, seam, or
|
||||
* library, and an unclassifiable entry hard-errors the generator.
|
||||
*
|
||||
* `tsx scripts/gen-config-catalog.ts` → write the catalog
|
||||
* `tsx scripts/gen-config-catalog.ts --check` → exit 1 if the committed
|
||||
* catalog is stale (CI /
|
||||
* pre-push gate)
|
||||
*
|
||||
* What the walk enforces (aggregated into one error, like the sibling
|
||||
* generators):
|
||||
*
|
||||
* - CLASSIFICATION is total. Every package entry resolves, mirroring the
|
||||
* cordis Loader's `unwrapExports` (`exports.default ?? exports`), to a
|
||||
* loadable plugin (default class / `apply` function), an abstract seam
|
||||
* class, or a plain library. Anything else is an error, not a skip.
|
||||
* - The CONFIG TYPE is the declared type of the plugin's second parameter
|
||||
* (`apply(ctx, config)` / `constructor(ctx, config)`) — the type cordis
|
||||
* actually passes — and it must resolve to a declaration inside the owning
|
||||
* package (entry file or a package-local relative import).
|
||||
* - Every property of a pasted declaration carries non-empty JSDoc prose: the
|
||||
* paste IS the documentation, so an undocumented field is a gate failure,
|
||||
* the same forcing function the events catalog applies via `@mode`.
|
||||
* - Every type NAME a pasted declaration references resolves: pasted
|
||||
* transitively when package-local, linked when it is another plugin's
|
||||
* config type / a core-data-structures entry / a workspace or external
|
||||
* import. An unresolvable name is an error, and so is a NAME COLLISION —
|
||||
* two distinct declarations, or a declaration and an import, sharing one
|
||||
* name across the closure (a verbatim fence has a single flat namespace) —
|
||||
* never a silent skip.
|
||||
* - The runtime schemastery schema (`Config` export or `static Config`),
|
||||
* when present, is walked statically — `z.object` keys, nested object/array
|
||||
* compositions as key PATHS (`agents[].id`), and `z.intersect` composition
|
||||
* across packages — and every schema-validated key path must be locatable
|
||||
* on the declared config type, resolving package-local and
|
||||
* workspace-imported types, re-export chains, intersections, utility
|
||||
* wrappers, and indexed access. The paste cannot hide a loader-accepted
|
||||
* field, top-level or nested. A path that crosses a type the walk cannot
|
||||
* enumerate (an external package's type) is skipped, never mis-reported,
|
||||
* and nested keys under dynamic-key shapes (`z.dict`) or union alternatives
|
||||
* contribute no paths. The reverse direction is deliberately NOT checked: a
|
||||
* declared field may be a runtime-only seam the schema excludes (e.g. the
|
||||
* ACP bridge's test-injected `stream`).
|
||||
*
|
||||
* Config fences use the ` ```ts config-catalog ` info string: doc-typecheck
|
||||
* recognizes it and skips compilation (a lone interface referencing imported
|
||||
* types is not standalone-compilable, like the ` ```ts cordis-catalog `
|
||||
* signature blocks).
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { LINK_MAP } from './gen-cordis-catalog.ts'
|
||||
import { parseJsDoc, pointer, rawJsDoc } from './jsdoc.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'docs/config-catalog.md'
|
||||
|
||||
/** The fenced-block info string for pasted config declarations (skipped by
|
||||
* doc-typecheck, since a lone declaration referencing imports is not
|
||||
* standalone-compilable). */
|
||||
const FENCE = 'ts config-catalog'
|
||||
|
||||
/** TypeScript/Node global type names a config declaration may reference
|
||||
* without importing; never treated as unresolved. Extend when a new global
|
||||
* legitimately appears — the generator hard-errors on unknown names, so an
|
||||
* omission is loud, not silent. */
|
||||
const GLOBAL_TYPES = new Set([
|
||||
'Array', 'ReadonlyArray', 'Record', 'Partial', 'Required', 'Readonly', 'Pick', 'Omit',
|
||||
'Promise', 'Map', 'Set', 'Date', 'Error', 'RegExp', 'Exclude', 'Extract', 'NonNullable',
|
||||
'ReturnType', 'Parameters', 'AbortSignal', 'URL', 'Buffer', 'NodeJS', 'Iterable', 'AsyncIterable',
|
||||
])
|
||||
|
||||
/** How a package classifies for the catalog. */
|
||||
type Kind = 'config' | 'no-config' | 'seam' | 'library'
|
||||
|
||||
/** One name a pasted declaration references but the paste does not contain. */
|
||||
interface TypeRef {
|
||||
/** The name as it appears in the pasted text (the local import alias). */
|
||||
alias: string
|
||||
/** The name the source module exports it under (pre-alias). */
|
||||
imported: string
|
||||
/** The import module specifier (package name or external module). */
|
||||
specifier: string
|
||||
}
|
||||
|
||||
/** One verbatim declaration paste. */
|
||||
interface Paste {
|
||||
/** Full source text: leading JSDoc (when present) through the closing token. */
|
||||
text: string
|
||||
/** Source pointer `packages/…/file.ts:line` of the declaration. */
|
||||
source: string
|
||||
}
|
||||
|
||||
/** One package's catalog entry. */
|
||||
export interface CatalogEntry {
|
||||
/** npm package name, e.g. `@deepseek-ai/dsh-agent-loop`. */
|
||||
pkg: string
|
||||
/** Repo-relative package dir, e.g. `packages/core/agent-loop`. */
|
||||
dir: string
|
||||
/** Repo-relative entry file, `<dir>/src/index.ts`. */
|
||||
entry: string
|
||||
kind: Kind
|
||||
/** Service keys the plugin `inject`s (empty when none declared). */
|
||||
inject: string[]
|
||||
/** Seam/service class name (kinds `seam` and class-based plugins). */
|
||||
className?: string
|
||||
/** Name of the config type (kind `config`). */
|
||||
configTypeName?: string
|
||||
/** Verbatim declaration pastes, the config type first (kind `config`). */
|
||||
pastes?: Paste[]
|
||||
/** References the pastes leave unresolved locally (kind `config`). */
|
||||
refs?: TypeRef[]
|
||||
/** Top-level keys and nested key paths (`agents[].id`) of the runtime
|
||||
* schema, `null` when no schema exists (kind `config`). */
|
||||
schemaKeys?: string[] | null
|
||||
/** Package names whose schemas an intersect composes (kind `config`). */
|
||||
schemaComposes?: string[]
|
||||
}
|
||||
|
||||
/** A parsed source file plus its import map (local name → origin). */
|
||||
interface FileCtx {
|
||||
abs: string
|
||||
rel: string
|
||||
text: string
|
||||
sf: ts.SourceFile
|
||||
/** Local binding name → `{ imported, specifier }`; default imports record
|
||||
* `imported: 'default'`. */
|
||||
imports: Map<string, { imported: string; specifier: string }>
|
||||
}
|
||||
|
||||
/** Throw one aggregate error for every violation the walk collected. */
|
||||
function report(violations: string[]): void {
|
||||
if (violations.length === 0) return
|
||||
throw new Error(
|
||||
`gen-config-catalog: ${violations.length} violation(s):\n`
|
||||
+ violations.map(v => ` ${v}`).join('\n'),
|
||||
)
|
||||
}
|
||||
|
||||
/** Parse a source file and index its import declarations. */
|
||||
function loadFile(abs: string, rel: string, cache: Map<string, FileCtx>): FileCtx {
|
||||
const cached = cache.get(abs)
|
||||
if (cached) return cached
|
||||
const text = readFileSync(abs, 'utf8')
|
||||
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
|
||||
const imports = new Map<string, { imported: string; specifier: string }>()
|
||||
for (const stmt of sf.statements) {
|
||||
if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue
|
||||
const specifier = stmt.moduleSpecifier.text
|
||||
const clause = stmt.importClause
|
||||
if (!clause) continue
|
||||
if (clause.name) imports.set(clause.name.text, { imported: 'default', specifier })
|
||||
if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
|
||||
for (const el of clause.namedBindings.elements) {
|
||||
imports.set(el.name.text, { imported: (el.propertyName ?? el.name).text, specifier })
|
||||
}
|
||||
}
|
||||
if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) {
|
||||
imports.set(clause.namedBindings.name.text, { imported: '*', specifier })
|
||||
}
|
||||
}
|
||||
const ctx = { abs, rel, text, sf, imports }
|
||||
cache.set(abs, ctx)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** A type declaration a paste can contain. */
|
||||
type TypeDecl = ts.InterfaceDeclaration | ts.TypeAliasDeclaration
|
||||
|
||||
/** Find an interface/type-alias declaration by name in a file, or null. */
|
||||
function findTypeDecl(ctx: FileCtx, name: string): TypeDecl | null {
|
||||
for (const stmt of ctx.sf.statements) {
|
||||
if ((ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)) && stmt.name.text === name) return stmt
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a type name from a file to its declaration (following package-local
|
||||
* relative imports transitively) or to the import that brings it in. Returns
|
||||
* `null` when the name is neither declared, imported, nor a known global.
|
||||
*/
|
||||
function resolveTypeName(
|
||||
ctx: FileCtx,
|
||||
name: string,
|
||||
cache: Map<string, FileCtx>,
|
||||
violations: string[],
|
||||
): { decl: TypeDecl; ctx: FileCtx } | { ref: TypeRef } | null {
|
||||
const local = findTypeDecl(ctx, name)
|
||||
if (local) return { decl: local, ctx }
|
||||
const imp = ctx.imports.get(name)
|
||||
if (!imp) return null
|
||||
if (imp.specifier.startsWith('.')) {
|
||||
if (!imp.specifier.endsWith('.ts')) {
|
||||
violations.push(`${ctx.rel}: relative import '${imp.specifier}' lacks the explicit .ts extension the repo convention requires.`)
|
||||
return null
|
||||
}
|
||||
if (imp.imported !== name) {
|
||||
violations.push(`${ctx.rel}: '${name}' aliases '${imp.imported}' across a package-local import; the catalog pastes declarations verbatim, so keep package-local config types unaliased.`)
|
||||
return null
|
||||
}
|
||||
const abs = resolve(dirname(ctx.abs), imp.specifier)
|
||||
const rel = ctx.rel.slice(0, ctx.rel.lastIndexOf('/') + 1) + imp.specifier.replace(/^\.\//, '')
|
||||
const target = loadFile(abs, rel, cache)
|
||||
return resolveTypeName(target, imp.imported, cache, violations)
|
||||
}
|
||||
return { ref: { alias: name, imported: imp.imported, specifier: imp.specifier } }
|
||||
}
|
||||
|
||||
/** Collect every type NAME referenced in type positions under a node. */
|
||||
function collectTypeNames(node: ts.Node, out: Set<string>): void {
|
||||
const visit = (n: ts.Node): void => {
|
||||
if (ts.isTypeReferenceNode(n)) {
|
||||
let head: ts.EntityName = n.typeName
|
||||
while (ts.isQualifiedName(head)) head = head.left
|
||||
out.add(head.text)
|
||||
} else if (ts.isExpressionWithTypeArguments(n) && ts.isIdentifier(n.expression)) {
|
||||
out.add(n.expression.text) // heritage clause: `extends X`
|
||||
}
|
||||
ts.forEachChild(n, visit)
|
||||
}
|
||||
visit(node)
|
||||
}
|
||||
|
||||
/** The verbatim paste text of a declaration: leading JSDoc through the end. */
|
||||
function pasteText(ctx: FileCtx, decl: TypeDecl): string {
|
||||
const raw = rawJsDoc(ctx.text, decl)
|
||||
const start = raw ? ctx.text.indexOf(raw, decl.getFullStart()) : decl.getStart(ctx.sf)
|
||||
return ctx.text.slice(start, decl.end)
|
||||
}
|
||||
|
||||
/** Enforce non-empty JSDoc prose on every property of a pasted declaration,
|
||||
* recursing into nested type literals (e.g. an array-of-objects field). */
|
||||
function checkMemberDocs(ctx: FileCtx, decl: TypeDecl, violations: string[]): void {
|
||||
const walkMembers = (members: ts.NodeArray<ts.TypeElement>, path: string): void => {
|
||||
for (const member of members) {
|
||||
if (!ts.isPropertySignature(member)) continue
|
||||
const name = member.name.getText(ctx.sf)
|
||||
const where = `config field '${path}.${name}' (${pointer(ctx.rel, ctx.sf, member)})`
|
||||
if (!parseJsDoc(rawJsDoc(ctx.text, member)).doc) violations.push(`${where} has no JSDoc prose.`)
|
||||
if (member.type) walkNested(member.type, `${path}.${name}`)
|
||||
}
|
||||
}
|
||||
const walkNested = (type: ts.Node, path: string): void => {
|
||||
if (ts.isTypeLiteralNode(type)) walkMembers(type.members, path)
|
||||
else ts.forEachChild(type, (n) => { walkNested(n, path) })
|
||||
}
|
||||
if (ts.isInterfaceDeclaration(decl)) walkMembers(decl.members, decl.name.text)
|
||||
else walkNested(decl.type, decl.name.text)
|
||||
}
|
||||
|
||||
/** Cross-file resolution context for the schema-path check. */
|
||||
interface World {
|
||||
scanRoot: string
|
||||
cache: Map<string, FileCtx>
|
||||
/** Workspace package name → repo-relative package dir. */
|
||||
pkgDirByName: Map<string, string>
|
||||
}
|
||||
|
||||
/** How a schema key path fared against the declared config type: definitely
|
||||
* present, definitely absent, or crossing a shape the walk cannot enumerate
|
||||
* (only `missing` is a violation — `unknown` must never mis-report). */
|
||||
type PathLookup = 'found' | 'missing' | 'unknown'
|
||||
|
||||
/** One step of a schema key path: a named member, or an array-element hop. */
|
||||
type PathStep = { member: string } | { array: true }
|
||||
|
||||
/** Parse a schema key path (`agents[].id`) into member/array steps. */
|
||||
function parsePath(path: string): PathStep[] {
|
||||
const steps: PathStep[] = []
|
||||
for (const seg of path.split('.')) {
|
||||
let name = seg
|
||||
let arrays = 0
|
||||
while (name.endsWith('[]')) {
|
||||
name = name.slice(0, -2)
|
||||
arrays += 1
|
||||
}
|
||||
steps.push({ member: name })
|
||||
for (let i = 0; i < arrays; i += 1) steps.push({ array: true })
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
/** Load a package-relative import target as a FileCtx. */
|
||||
function loadRelative(world: World, from: FileCtx, specifier: string): FileCtx {
|
||||
const abs = resolve(dirname(from.abs), specifier)
|
||||
const rel = from.rel.slice(0, from.rel.lastIndexOf('/') + 1) + specifier.replace(/^\.\//, '')
|
||||
return loadFile(abs, rel, world.cache)
|
||||
}
|
||||
|
||||
/** Find a type declaration EXPORTED (directly or via re-export chains) from a
|
||||
* file, following `export … from './x.ts'` and `export * from './x.ts'`. */
|
||||
function findExportedTypeDecl(world: World, ctx: FileCtx, name: string, seen = new Set<string>()): { decl: TypeDecl; ctx: FileCtx } | null {
|
||||
const key = `${ctx.abs}#${name}`
|
||||
if (seen.has(key)) return null
|
||||
seen.add(key)
|
||||
const local = findTypeDecl(ctx, name)
|
||||
if (local) return { decl: local, ctx }
|
||||
for (const stmt of ctx.sf.statements) {
|
||||
if (!ts.isExportDeclaration(stmt) || !stmt.moduleSpecifier || !ts.isStringLiteral(stmt.moduleSpecifier)) continue
|
||||
const spec = stmt.moduleSpecifier.text
|
||||
if (!spec.startsWith('.') || !spec.endsWith('.ts')) continue
|
||||
let lookFor: string | null = null
|
||||
if (!stmt.exportClause) {
|
||||
lookFor = name // export * from './x.ts'
|
||||
} else if (ts.isNamedExports(stmt.exportClause)) {
|
||||
const el = stmt.exportClause.elements.find(e => e.name.text === name)
|
||||
if (el) lookFor = (el.propertyName ?? el.name).text
|
||||
}
|
||||
if (lookFor === null) continue
|
||||
const hit = findExportedTypeDecl(world, loadRelative(world, ctx, spec), lookFor, seen)
|
||||
if (hit) return hit
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Resolve a referenced type NAME to its declaration: declared locally, via a
|
||||
* package-relative import, or via a workspace-package import (entry file +
|
||||
* re-export chains). `'unknown'` = external or otherwise out of reach. */
|
||||
function declForTypeName(world: World, ctx: FileCtx, name: string): { decl: TypeDecl; ctx: FileCtx } | 'unknown' {
|
||||
const local = findTypeDecl(ctx, name)
|
||||
if (local) return { decl: local, ctx }
|
||||
const imp = ctx.imports.get(name)
|
||||
if (!imp) return 'unknown'
|
||||
if (imp.specifier.startsWith('.')) {
|
||||
if (!imp.specifier.endsWith('.ts')) return 'unknown'
|
||||
return findExportedTypeDecl(world, loadRelative(world, ctx, imp.specifier), imp.imported) ?? 'unknown'
|
||||
}
|
||||
const dir = world.pkgDirByName.get(imp.specifier)
|
||||
if (dir === undefined) return 'unknown'
|
||||
const entryRel = `${dir}/src/index.ts`
|
||||
let entry: FileCtx
|
||||
try {
|
||||
entry = loadFile(resolve(world.scanRoot, entryRel), entryRel, world.cache)
|
||||
} catch {
|
||||
// A workspace package without a readable entry is reported by its own
|
||||
// classification pass; for a lookup it is merely out of reach.
|
||||
return 'unknown'
|
||||
}
|
||||
return findExportedTypeDecl(world, entry, imp.imported) ?? 'unknown'
|
||||
}
|
||||
|
||||
/** Utility wrappers that pass a member lookup through to their type argument. */
|
||||
const PASSTHROUGH_WRAPPERS = new Set(['Partial', 'Required', 'Readonly', 'NonNullable'])
|
||||
|
||||
/**
|
||||
* Walk a schema key path against a declared type. This is a PRESENCE check,
|
||||
* not a shape check: it answers "does the declared config type have a member
|
||||
* here", resolving interfaces (heritage included), type aliases, literals,
|
||||
* intersections, unions, arrays, indexed access, pass-through utility
|
||||
* wrappers, and type references across package-local and workspace imports.
|
||||
* Anything it cannot see through resolves `'unknown'`, never `'missing'`.
|
||||
*/
|
||||
function lookupPath(world: World, ctx: FileCtx, node: ts.Node, steps: PathStep[], seen: Set<string>): PathLookup {
|
||||
if (steps.length === 0) return 'found'
|
||||
// Guard recursion at NAMED declarations only — the sole way a walk can loop
|
||||
// (a recursive interface/alias). Structural nodes must not be guarded: a
|
||||
// first child shares `.pos` with its parent, so a span-keyed guard there
|
||||
// would mistake ordinary descent for a cycle.
|
||||
if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) {
|
||||
const key = `${ctx.abs}:${node.pos}:${steps.length}`
|
||||
if (seen.has(key)) return 'unknown' // recursive type — bail rather than loop
|
||||
seen.add(key)
|
||||
}
|
||||
const step = steps[0]
|
||||
if (step === undefined) return 'found'
|
||||
// Combine branch results: any found wins, else any unknown taints, else missing.
|
||||
const combine = (results: PathLookup[]): PathLookup => {
|
||||
if (results.includes('found')) return 'found'
|
||||
if (results.includes('unknown')) return 'unknown'
|
||||
return 'missing'
|
||||
}
|
||||
const intoMembers = (members: ts.NodeArray<ts.TypeElement>): PathLookup | null => {
|
||||
if (!('member' in step)) return null
|
||||
for (const m of members) {
|
||||
if (!ts.isPropertySignature(m) || m.name.getText(ctx.sf) !== step.member) continue
|
||||
if (steps.length === 1) return 'found'
|
||||
return m.type ? lookupPath(world, ctx, m.type, steps.slice(1), seen) : 'unknown'
|
||||
}
|
||||
return null // not among these members; caller consults heritage/parts
|
||||
}
|
||||
if (ts.isInterfaceDeclaration(node)) {
|
||||
if (!('member' in step)) return 'unknown' // an array step cannot land on an interface
|
||||
const direct = intoMembers(node.members)
|
||||
if (direct !== null) return direct
|
||||
const bases: PathLookup[] = []
|
||||
for (const clause of node.heritageClauses ?? []) {
|
||||
for (const base of clause.types) {
|
||||
if (!ts.isIdentifier(base.expression)) {
|
||||
bases.push('unknown')
|
||||
continue
|
||||
}
|
||||
const resolved = declForTypeName(world, ctx, base.expression.text)
|
||||
bases.push(resolved === 'unknown' ? 'unknown' : lookupPath(world, resolved.ctx, resolved.decl, steps, seen))
|
||||
}
|
||||
}
|
||||
return bases.length ? combine(bases) : 'missing'
|
||||
}
|
||||
if (ts.isTypeAliasDeclaration(node)) return lookupPath(world, ctx, node.type, steps, seen)
|
||||
if (ts.isTypeLiteralNode(node)) {
|
||||
if (!('member' in step)) return 'unknown'
|
||||
return intoMembers(node.members) ?? 'missing'
|
||||
}
|
||||
if (ts.isParenthesizedTypeNode(node)) return lookupPath(world, ctx, node.type, steps, seen)
|
||||
if (ts.isIntersectionTypeNode(node)) {
|
||||
return combine(node.types.map(t => lookupPath(world, ctx, t, steps, seen)))
|
||||
}
|
||||
if (ts.isUnionTypeNode(node)) {
|
||||
// Presence on a union is only definite when every branch agrees.
|
||||
const results = node.types.map(t => lookupPath(world, ctx, t, steps, seen))
|
||||
if (results.every(r => r === 'found')) return 'found'
|
||||
if (results.every(r => r === 'missing')) return 'missing'
|
||||
return 'unknown'
|
||||
}
|
||||
if (ts.isArrayTypeNode(node)) {
|
||||
return 'array' in step ? lookupPath(world, ctx, node.elementType, steps.slice(1), seen) : 'unknown'
|
||||
}
|
||||
if (ts.isTypeOperatorNode(node)) return lookupPath(world, ctx, node.type, steps, seen)
|
||||
if (ts.isIndexedAccessTypeNode(node)) {
|
||||
const index = node.indexType
|
||||
if (ts.isLiteralTypeNode(index) && ts.isStringLiteral(index.literal)) {
|
||||
return lookupPath(world, ctx, node.objectType, [{ member: index.literal.text }, ...steps], seen)
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
if (ts.isTypeReferenceNode(node)) {
|
||||
let head: ts.EntityName = node.typeName
|
||||
while (ts.isQualifiedName(head)) head = head.left
|
||||
const name = head.text
|
||||
if (PASSTHROUGH_WRAPPERS.has(name) && node.typeArguments?.[0]) {
|
||||
return lookupPath(world, ctx, node.typeArguments[0], steps, seen)
|
||||
}
|
||||
if ((name === 'Array' || name === 'ReadonlyArray') && node.typeArguments?.[0]) {
|
||||
return 'array' in step ? lookupPath(world, ctx, node.typeArguments[0], steps.slice(1), seen) : 'unknown'
|
||||
}
|
||||
if (!ts.isIdentifier(node.typeName)) return 'unknown' // namespace-qualified: out of reach
|
||||
const resolved = declForTypeName(world, ctx, name)
|
||||
return resolved === 'unknown' ? 'unknown' : lookupPath(world, resolved.ctx, resolved.decl, steps, seen)
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/** Unwrap `as` / `satisfies` / parenthesized wrappers around an expression. */
|
||||
function unwrapExpr(expr: ts.Expression): ts.Expression {
|
||||
let e = expr
|
||||
while (ts.isAsExpression(e) || ts.isSatisfiesExpression(e) || ts.isParenthesizedExpression(e)) e = e.expression
|
||||
return e
|
||||
}
|
||||
|
||||
/**
|
||||
* Statically walk a schemastery schema expression to its key paths plus the
|
||||
* packages whose schemas an intersect composes. A key path is the top-level
|
||||
* key or a nested path through object/array compositions (`agents[].id`).
|
||||
* Handles the shapes the repo declares — `z.object({…})` (possibly behind
|
||||
* chained calls) and `z.intersect([X.Config, …])` — and hard-errors on
|
||||
* anything else, so a schema the walk cannot see fails the gate instead of
|
||||
* silently thinning it. Nested values that are neither `object` nor `array`
|
||||
* compositions (primitives, unions, dynamic-key dicts) contribute no paths.
|
||||
*/
|
||||
function walkSchemaExpr(
|
||||
ctx: FileCtx,
|
||||
expr: ts.Expression,
|
||||
where: string,
|
||||
violations: string[],
|
||||
): { keys: string[]; composes: string[] } {
|
||||
const keys: string[] = []
|
||||
const composes: string[] = []
|
||||
// Nested paths under one object property's VALUE expression: recurse through
|
||||
// chained refinements toward the base call, descending into object/array.
|
||||
const collectValuePaths = (value: ts.Expression, base: string): void => {
|
||||
const call = unwrapExpr(value)
|
||||
if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression)) return
|
||||
const method = call.expression.name.text
|
||||
if (method === 'object' && call.arguments[0] && ts.isObjectLiteralExpression(call.arguments[0])) {
|
||||
for (const prop of call.arguments[0].properties) {
|
||||
if (!ts.isPropertyAssignment(prop)) continue
|
||||
const key = ts.isStringLiteral(prop.name) ? prop.name.text : prop.name.getText(ctx.sf)
|
||||
keys.push(`${base}.${key}`)
|
||||
collectValuePaths(prop.initializer, `${base}.${key}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (method === 'array' && call.arguments[0]) {
|
||||
collectValuePaths(call.arguments[0], `${base}[]`)
|
||||
return
|
||||
}
|
||||
const inner = unwrapExpr(call.expression.expression)
|
||||
if (ts.isCallExpression(inner)) collectValuePaths(inner, base)
|
||||
}
|
||||
const visit = (e: ts.Expression): void => {
|
||||
const call = unwrapExpr(e)
|
||||
if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression)) {
|
||||
violations.push(`${where}: schema expression is not a statically walkable schemastery call.`)
|
||||
return
|
||||
}
|
||||
const method = call.expression.name.text
|
||||
if (method === 'object' && call.arguments[0] && ts.isObjectLiteralExpression(call.arguments[0])) {
|
||||
for (const prop of call.arguments[0].properties) {
|
||||
if (ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop)) {
|
||||
const key = ts.isStringLiteral(prop.name) ? prop.name.text : prop.name.getText(ctx.sf)
|
||||
keys.push(key)
|
||||
if (ts.isPropertyAssignment(prop)) collectValuePaths(prop.initializer, key)
|
||||
} else {
|
||||
violations.push(`${where}: schema object property '${prop.getText(ctx.sf)}' is not a plain key.`)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (method === 'intersect' && call.arguments[0] && ts.isArrayLiteralExpression(call.arguments[0])) {
|
||||
for (const el of call.arguments[0].elements) {
|
||||
const part = unwrapExpr(el)
|
||||
if (ts.isPropertyAccessExpression(part) && part.name.text === 'Config' && ts.isIdentifier(part.expression)) {
|
||||
const imp = ctx.imports.get(part.expression.text)
|
||||
if (imp && !imp.specifier.startsWith('.')) { composes.push(imp.specifier); continue }
|
||||
}
|
||||
if (ts.isCallExpression(part)) { visit(part); continue }
|
||||
violations.push(`${where}: intersect element '${part.getText(ctx.sf)}' is neither a workspace plugin's Config nor an inline schema call.`)
|
||||
}
|
||||
return
|
||||
}
|
||||
// A chained refinement (`z.object({…}).default(…)` etc.): the keys live on
|
||||
// the call the chain hangs off — keep unwrapping toward it.
|
||||
const base = unwrapExpr(call.expression.expression)
|
||||
if (ts.isCallExpression(base)) { visit(base); return }
|
||||
violations.push(`${where}: schema call '${method}' is not object/intersect and hangs off no walkable base call.`)
|
||||
}
|
||||
visit(expr)
|
||||
return { keys, composes }
|
||||
}
|
||||
|
||||
/** Find a plugin's schemastery schema expression: an exported `const Config`
|
||||
* in the entry file, else a `static Config` on the plugin class. */
|
||||
function findSchemaExpr(ctx: FileCtx, pluginClass: ts.ClassDeclaration | null): ts.Expression | null {
|
||||
for (const stmt of ctx.sf.statements) {
|
||||
if (!ts.isVariableStatement(stmt)) continue
|
||||
if (!stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)) continue
|
||||
for (const decl of stmt.declarationList.declarations) {
|
||||
if (ts.isIdentifier(decl.name) && decl.name.text === 'Config' && decl.initializer) return decl.initializer
|
||||
}
|
||||
}
|
||||
for (const member of pluginClass?.members ?? []) {
|
||||
if (!ts.isPropertyDeclaration(member) || member.name.getText() !== 'Config') continue
|
||||
if (!member.modifiers?.some(m => m.kind === ts.SyntaxKind.StaticKeyword)) continue
|
||||
if (member.initializer) return member.initializer
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Read an `inject` service-key list: `export const inject = […]` in the entry
|
||||
* file, else `static inject = […]` on the plugin class. */
|
||||
function findInject(ctx: FileCtx, pluginClass: ts.ClassDeclaration | null, violations: string[]): string[] {
|
||||
const fromArray = (expr: ts.Expression, where: string): string[] => {
|
||||
if (!ts.isArrayLiteralExpression(expr)) {
|
||||
violations.push(`${where}: inject is not a plain string-array literal; teach the generator the new shape.`)
|
||||
return []
|
||||
}
|
||||
return expr.elements.map(el => ts.isStringLiteral(el) ? el.text : el.getText(ctx.sf))
|
||||
}
|
||||
for (const stmt of ctx.sf.statements) {
|
||||
if (!ts.isVariableStatement(stmt)) continue
|
||||
for (const decl of stmt.declarationList.declarations) {
|
||||
if (ts.isIdentifier(decl.name) && decl.name.text === 'inject' && decl.initializer) {
|
||||
return fromArray(decl.initializer, ctx.rel)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const member of pluginClass?.members ?? []) {
|
||||
if (ts.isPropertyDeclaration(member) && member.name.getText() === 'inject' && member.initializer) {
|
||||
return fromArray(member.initializer, ctx.rel)
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/** Resolve the entry file's default export to its class/function declaration
|
||||
* (mirroring the Loader's `unwrapExports`), or null when there is none. */
|
||||
function defaultExport(ctx: FileCtx): ts.ClassDeclaration | ts.FunctionDeclaration | null {
|
||||
for (const stmt of ctx.sf.statements) {
|
||||
if (ts.isExportAssignment(stmt) && !stmt.isExportEquals && ts.isIdentifier(stmt.expression)) {
|
||||
const name = stmt.expression.text
|
||||
for (const s of ctx.sf.statements) {
|
||||
if ((ts.isClassDeclaration(s) || ts.isFunctionDeclaration(s)) && s.name?.text === name) return s
|
||||
}
|
||||
return null
|
||||
}
|
||||
if ((ts.isClassDeclaration(stmt) || ts.isFunctionDeclaration(stmt))
|
||||
&& stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.DefaultKeyword)) return stmt
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Find the exported `apply` function declaration in the entry file, or null. */
|
||||
function applyExport(ctx: FileCtx): ts.FunctionDeclaration | null {
|
||||
for (const stmt of ctx.sf.statements) {
|
||||
if (ts.isFunctionDeclaration(stmt) && stmt.name?.text === 'apply'
|
||||
&& stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)) return stmt
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk every `packages/<group>/<pkg>` entry and build the catalog entries.
|
||||
* Hard-errors (aggregated) on any violation listed in the module doc.
|
||||
* `scanRoot` defaults to the repo root; tests pass a fixture dir.
|
||||
*/
|
||||
export function collectConfigCatalog(scanRoot: string = root): CatalogEntry[] {
|
||||
const violations: string[] = []
|
||||
const cache = new Map<string, FileCtx>()
|
||||
const entries: CatalogEntry[] = []
|
||||
|
||||
// Pre-pass: package name → dir, so schema-path lookups can follow
|
||||
// workspace-package imports while individual packages are still being walked.
|
||||
const pkgDirByName = new Map<string, string>()
|
||||
const manifests: { dir: string; pkg: string }[] = []
|
||||
for (const manifestRel of globSync('packages/*/*/package.json', { cwd: scanRoot }).sort()) {
|
||||
const dir = manifestRel.slice(0, -'/package.json'.length)
|
||||
const pkg = (JSON.parse(readFileSync(resolve(scanRoot, manifestRel), 'utf8')) as { name?: string }).name
|
||||
if (!pkg) {
|
||||
violations.push(`${manifestRel} has no "name".`)
|
||||
continue
|
||||
}
|
||||
pkgDirByName.set(pkg, dir)
|
||||
manifests.push({ dir, pkg })
|
||||
}
|
||||
const world: World = { scanRoot, cache, pkgDirByName }
|
||||
|
||||
for (const { dir, pkg } of manifests) {
|
||||
const entryRel = `${dir}/src/index.ts`
|
||||
let ctx: FileCtx
|
||||
try {
|
||||
ctx = loadFile(resolve(scanRoot, entryRel), entryRel, cache)
|
||||
} catch {
|
||||
// A package without src/index.ts cannot be classified — that is the
|
||||
// violation itself; nothing else in this loop body can run without it.
|
||||
violations.push(`${pkg}: entry ${entryRel} is missing or unreadable.`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Classify, mirroring the Loader's unwrapExports: the default export IS
|
||||
// the plugin when present; else an exported `apply` makes the module
|
||||
// namespace the plugin; else the package is a plain library.
|
||||
const dflt = defaultExport(ctx)
|
||||
const apply = applyExport(ctx)
|
||||
let pluginClass: ts.ClassDeclaration | null = null
|
||||
let configParam: ts.ParameterDeclaration | undefined
|
||||
let kind: Kind
|
||||
let className: string | undefined
|
||||
if (dflt && ts.isClassDeclaration(dflt)) {
|
||||
className = dflt.name?.text
|
||||
if (dflt.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword)) {
|
||||
kind = 'seam'
|
||||
} else {
|
||||
pluginClass = dflt
|
||||
const ctor = dflt.members.find(ts.isConstructorDeclaration)
|
||||
configParam = ctor?.parameters[1]
|
||||
kind = configParam ? 'config' : 'no-config'
|
||||
}
|
||||
} else if (dflt) {
|
||||
configParam = dflt.parameters[1]
|
||||
kind = configParam ? 'config' : 'no-config'
|
||||
} else if (apply) {
|
||||
configParam = apply.parameters[1]
|
||||
kind = configParam ? 'config' : 'no-config'
|
||||
} else {
|
||||
kind = 'library'
|
||||
}
|
||||
|
||||
const entry: CatalogEntry = {
|
||||
pkg,
|
||||
dir,
|
||||
entry: entryRel,
|
||||
kind,
|
||||
inject: kind === 'library' || kind === 'seam' ? [] : findInject(ctx, pluginClass, violations),
|
||||
...className !== undefined ? { className } : {},
|
||||
}
|
||||
entries.push(entry)
|
||||
if (kind !== 'config' || !configParam) continue
|
||||
|
||||
// Resolve the config type and paste its package-local transitive closure.
|
||||
if (!configParam.type || !ts.isTypeReferenceNode(configParam.type) || !ts.isIdentifier(configParam.type.typeName)) {
|
||||
violations.push(`${pkg}: config parameter type (${pointer(entryRel, ctx.sf, configParam)}) is not a plain type-name reference; declare a named config type.`)
|
||||
continue
|
||||
}
|
||||
const typeName = configParam.type.typeName.text
|
||||
entry.configTypeName = typeName
|
||||
const pastes: Paste[] = []
|
||||
const refs = new Map<string, TypeRef>()
|
||||
// A bare name is the fence's whole namespace: two DIFFERENT declarations
|
||||
// (or a declaration in one file and an import in another) sharing a name
|
||||
// cannot both render unambiguously, so every resolution is identity-checked
|
||||
// by source pointer and a collision is a violation, never a silent skip.
|
||||
const pastedDeclByName = new Map<string, string>()
|
||||
const queue: { name: string; from: FileCtx }[] = [{ name: typeName, from: ctx }]
|
||||
for (let item = queue.shift(); item !== undefined; item = queue.shift()) {
|
||||
const { name, from } = item
|
||||
const resolved = resolveTypeName(from, name, cache, violations)
|
||||
if (resolved === null) {
|
||||
violations.push(`${pkg}: config declaration references '${name}' (via ${from.rel}), which is neither declared in the package, imported, nor a known global type.`)
|
||||
continue
|
||||
}
|
||||
if ('ref' in resolved) {
|
||||
if (name === typeName) {
|
||||
violations.push(`${pkg}: config type '${name}' is imported from '${resolved.ref.specifier}'; a plugin's config type must live in its own package.`)
|
||||
continue
|
||||
}
|
||||
if (pastedDeclByName.has(name)) {
|
||||
violations.push(`${pkg}: '${name}' resolves to a package-local declaration (${pastedDeclByName.get(name) ?? ''}) in one file and an import from '${resolved.ref.specifier}' in another; rename one so the fence is unambiguous.`)
|
||||
continue
|
||||
}
|
||||
const existing = refs.get(name)
|
||||
if (existing && (existing.specifier !== resolved.ref.specifier || existing.imported !== resolved.ref.imported)) {
|
||||
violations.push(`${pkg}: '${name}' is imported from both '${existing.specifier}' (${existing.imported}) and '${resolved.ref.specifier}' (${resolved.ref.imported}) across the pasted closure; disambiguate the aliases.`)
|
||||
continue
|
||||
}
|
||||
refs.set(name, resolved.ref)
|
||||
continue
|
||||
}
|
||||
const declKey = pointer(resolved.ctx.rel, resolved.ctx.sf, resolved.decl)
|
||||
const prior = pastedDeclByName.get(name)
|
||||
if (prior === declKey) continue // same declaration reached again — benign
|
||||
if (prior !== undefined) {
|
||||
violations.push(`${pkg}: type name '${name}' resolves to two different declarations (${prior} and ${declKey}) across the pasted closure; rename one — a verbatim fence cannot carry two same-named declarations.`)
|
||||
continue
|
||||
}
|
||||
if (refs.has(name)) {
|
||||
violations.push(`${pkg}: '${name}' resolves to an import from '${refs.get(name)?.specifier ?? ''}' in one file and a package-local declaration (${declKey}) in another; rename one so the fence is unambiguous.`)
|
||||
continue
|
||||
}
|
||||
pastedDeclByName.set(name, declKey)
|
||||
pastes.push({ text: pasteText(resolved.ctx, resolved.decl), source: declKey })
|
||||
checkMemberDocs(resolved.ctx, resolved.decl, violations)
|
||||
const names = new Set<string>()
|
||||
collectTypeNames(resolved.decl, names)
|
||||
for (const n of names) {
|
||||
if (GLOBAL_TYPES.has(n)) continue
|
||||
queue.push({ name: n, from: resolved.ctx })
|
||||
}
|
||||
}
|
||||
entry.pastes = pastes
|
||||
entry.refs = [...refs.values()].sort((a, b) => a.alias.localeCompare(b.alias))
|
||||
|
||||
// Statically walk the runtime schema (when one exists) for the subset check.
|
||||
const schemaExpr = findSchemaExpr(ctx, pluginClass)
|
||||
if (schemaExpr) {
|
||||
const { keys, composes } = walkSchemaExpr(ctx, unwrapExpr(schemaExpr), `${pkg} (${entryRel})`, violations)
|
||||
entry.schemaKeys = keys
|
||||
entry.schemaComposes = composes
|
||||
} else {
|
||||
entry.schemaKeys = null
|
||||
}
|
||||
}
|
||||
|
||||
// Second phase: fold composed schemas' key paths in, then walk every
|
||||
// schema-validated path against the declared config type. Only a definite
|
||||
// miss is a violation — a path through a shape the walk cannot enumerate
|
||||
// stays silent rather than mis-reporting.
|
||||
const byName = new Map(entries.map(e => [e.pkg, e]))
|
||||
for (const entry of entries) {
|
||||
if (entry.kind !== 'config' || entry.schemaKeys === null || entry.schemaKeys === undefined) continue
|
||||
const seen = new Set<string>()
|
||||
const foldComposed = (e: CatalogEntry): string[] => {
|
||||
if (seen.has(e.pkg)) return []
|
||||
seen.add(e.pkg)
|
||||
const keys = [...e.schemaKeys ?? []]
|
||||
for (const composed of e.schemaComposes ?? []) {
|
||||
const target = byName.get(composed)
|
||||
if (!target) {
|
||||
violations.push(`${entry.pkg}: schema intersects '${composed}', which is not a workspace package the walk collected.`)
|
||||
continue
|
||||
}
|
||||
keys.push(...foldComposed(target))
|
||||
}
|
||||
return keys
|
||||
}
|
||||
const allKeys = foldComposed(entry)
|
||||
const mainPaste = entry.pastes?.[0]
|
||||
const mainFile = mainPaste?.source.split(':')[0]
|
||||
const mainCtx = mainFile !== undefined ? cache.get(resolve(scanRoot, mainFile)) : undefined
|
||||
const mainDecl = mainCtx && entry.configTypeName !== undefined ? findTypeDecl(mainCtx, entry.configTypeName) : null
|
||||
if (!mainCtx || !mainDecl) {
|
||||
violations.push(`${entry.pkg}: cannot locate config type '${entry.configTypeName ?? ''}' for the schema-path check.`)
|
||||
continue
|
||||
}
|
||||
for (const keyPath of allKeys) {
|
||||
if (lookupPath(world, mainCtx, mainDecl, parsePath(keyPath), new Set()) === 'missing') {
|
||||
violations.push(`${entry.pkg}: schema validates key '${keyPath}' but config type '${entry.configTypeName ?? ''}' declares no such member — the catalog paste would hide a loader-accepted field.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
report(violations)
|
||||
return entries.sort((a, b) => a.pkg.localeCompare(b.pkg))
|
||||
}
|
||||
|
||||
/** GitHub-style anchor slug for a `## \`pkg\`` heading. */
|
||||
function slug(heading: string): string {
|
||||
return heading.toLowerCase().replace(/[^a-z0-9 -]/g, '').replace(/ /g, '-')
|
||||
}
|
||||
|
||||
/** Render the `Requires:` service-key line, or '' when the plugin injects nothing. */
|
||||
function requiresLine(inject: string[]): string {
|
||||
return inject.length ? `Requires: ${inject.map(k => `\`${k}\``).join(' · ')}` : ''
|
||||
}
|
||||
|
||||
/** Render one reference as a link: another plugin's config type → its section,
|
||||
* a curated core-data-structures name → its page, any other workspace type →
|
||||
* its source file, an external type → named with its module, unlinked. */
|
||||
function refLink(ref: TypeRef, byName: Map<string, CatalogEntry>): string {
|
||||
const target = byName.get(ref.specifier)
|
||||
if (target?.kind === 'config' && ref.imported === target.configTypeName) {
|
||||
return `[\`${ref.alias}\`](#${slug(target.pkg)})`
|
||||
}
|
||||
const page = LINK_MAP[ref.imported]
|
||||
if (page) return `[\`${ref.alias}\`](core-data-structures/${page})`
|
||||
if (target) return `[\`${ref.alias}\`](../${target.entry})`
|
||||
return `\`${ref.alias}\` (\`${ref.specifier}\`)`
|
||||
}
|
||||
|
||||
/** Render one configurable plugin's section. */
|
||||
function renderConfigEntry(entry: CatalogEntry, byName: Map<string, CatalogEntry>): string[] {
|
||||
const out = [`## \`${entry.pkg}\``, '']
|
||||
const requires = requiresLine(entry.inject)
|
||||
if (requires) out.push(requires, '')
|
||||
out.push('```' + FENCE, ...(entry.pastes ?? []).map(p => p.text).join('\n\n').split('\n'), '```', '')
|
||||
if (entry.refs && entry.refs.length > 0) {
|
||||
out.push(`Depends on: ${entry.refs.map(r => refLink(r, byName)).join(' · ')}`, '')
|
||||
}
|
||||
const source = entry.pastes?.[0]?.source ?? entry.entry
|
||||
out.push(`Source: [\`${source}\`](../${source.split(':')[0]})`, '')
|
||||
return out
|
||||
}
|
||||
|
||||
/** Render one terse list line (the no-config / seam / library sections). */
|
||||
function renderTerse(entry: CatalogEntry, detail: string): string {
|
||||
const requires = entry.inject.length ? ` — requires ${entry.inject.map(k => `\`${k}\``).join(' · ')}` : ''
|
||||
return `- \`${entry.pkg}\`${detail}${requires} ([\`${entry.entry}\`](../${entry.entry}))`
|
||||
}
|
||||
|
||||
/** Render the full catalog (pure, deterministic given sorted entries). */
|
||||
export function render(entries: CatalogEntry[]): string {
|
||||
const byName = new Map(entries.map(e => [e.pkg, e]))
|
||||
const lines: string[] = [
|
||||
'<!-- Generated by scripts/gen-config-catalog.ts — do not edit by hand.',
|
||||
' Run `pnpm run gen-config-catalog` to regenerate. -->',
|
||||
'',
|
||||
'# Plugin Config Catalog',
|
||||
'',
|
||||
'Every `config:` block a `cordis.yml` entry can set: for each loadable harness package, the verbatim config declaration (JSDoc included) its `apply` function or service constructor receives, with every referenced type pasted alongside (package-local types) or linked (everything else). The paste is the plugin\'s full declared config type — a field the runtime schema deliberately excludes is a runtime-only seam (its own JSDoc says so) and is not settable from `cordis.yml`. This is the **deployment**-axis reference — the wiring a plugin author works against is the cordis [events](cordis-catalog/events.md) + [services](cordis-catalog/services.md) catalogs, the model-facing tool schemas are the [tool catalog](tool-catalog.md), and [core-data-structures/](core-data-structures/core.md) documents the types these declarations reference.',
|
||||
'',
|
||||
'This file is GENERATED from source (`scripts/gen-config-catalog.ts`) and verified fresh by `pnpm run verify-config-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks use a `ts config-catalog` fence (skipped by doc-typecheck, since a lone declaration referencing imports is not standalone-compilable). The generator also cross-checks the runtime schemastery schema against the pasted declaration — every schema-validated key, nested keys included, must be locatable on the declared config type — so the paste cannot hide a loader-accepted field.',
|
||||
'',
|
||||
'A `Requires:` line lists the service keys the plugin `inject`s: its `cordis.yml` tree must also load providers for those services. Scope is the harness tier (`packages/`); the vendored cordis plugins a config tree may also load (`hmr`, the console logger, …) are pinned upstream source ([vendoring policy](../vendor/README.md)) and not catalogued here.',
|
||||
'',
|
||||
]
|
||||
for (const entry of entries.filter(e => e.kind === 'config')) {
|
||||
lines.push(...renderConfigEntry(entry, byName))
|
||||
}
|
||||
lines.push(
|
||||
'## Loadable plugins with no config',
|
||||
'',
|
||||
'These load from a `cordis.yml` entry with no `config:` block; they declare no config surface.',
|
||||
'',
|
||||
...entries.filter(e => e.kind === 'no-config').map(e => renderTerse(e, '')),
|
||||
'',
|
||||
'## Seam packages (not directly loadable)',
|
||||
'',
|
||||
'Abstract service classes — a deployment loads a concrete implementation package instead ([capability seams](rfc/implemented/architecture/2026-06-13-capability-seams.md)).',
|
||||
'',
|
||||
...entries.filter(e => e.kind === 'seam').map(e => renderTerse(e, ` — abstract \`${e.className ?? ''}\``)),
|
||||
'',
|
||||
'## Library packages (no plugin entry)',
|
||||
'',
|
||||
'Imported as libraries by other packages; a `cordis.yml` cannot load them.',
|
||||
'',
|
||||
...entries.filter(e => e.kind === 'library').map(e => renderTerse(e, '')),
|
||||
'',
|
||||
)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/** CLI entry: default writes the catalog, `--check` fails if the committed
|
||||
* copy is stale. Guarded behind an entry-point check so importing this module
|
||||
* for tests neither regenerates the committed file nor calls process.exit. */
|
||||
function main(): void {
|
||||
const content = render(collectConfigCatalog())
|
||||
if (process.argv.includes('--check')) {
|
||||
let committed: string | null = null
|
||||
try {
|
||||
committed = readFileSync(resolve(root, OUT), 'utf8')
|
||||
} catch {
|
||||
// Only ENOENT (not yet generated) is expected; a present-but-unreadable
|
||||
// file is not a state this repo produces. Either way the remedy is the
|
||||
// same — regenerate — so treat a read failure as "stale".
|
||||
committed = null
|
||||
}
|
||||
if (committed === content) {
|
||||
console.log(`gen-config-catalog: ${OUT} is up to date.`)
|
||||
process.exit(0)
|
||||
}
|
||||
console.error(`gen-config-catalog: ${OUT} is stale. Run \`pnpm run gen-config-catalog\` and commit ${OUT}.`)
|
||||
process.exit(1)
|
||||
}
|
||||
writeFileSync(resolve(root, OUT), content)
|
||||
console.log(`gen-config-catalog: wrote ${OUT}.`)
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
247
scripts/gen-cordis-api.ts
Normal file
247
scripts/gen-cordis-api.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Generate (and verify) the runtime cordis API catalog the `cordis_inspect`
|
||||
* tool serves to the model: packages/cordis/tool-cordis/src/api-catalog.ts.
|
||||
*
|
||||
* The artifact is the machine-readable sibling of docs/cordis-catalog: it
|
||||
* reuses `collectServices` / `collectEvents` from `gen-cordis-catalog.ts` (the
|
||||
* same JSDoc-completeness-enforcing AST walk), so the API the model reads at
|
||||
* runtime and the API the docs render cannot diverge. Emitted as a typed
|
||||
* TypeScript data module (not JSON): it compiles under the package tsconfig,
|
||||
* passes lint and the export-JSDoc gate, and is trivially covered by import.
|
||||
*
|
||||
* The data is trimmed for a model-facing text surface: per service the
|
||||
* `ctx.<key>` name, the first sentence of the class doc, and the raw method
|
||||
* signatures; per event the name, `@mode`, signature, and first sentence of
|
||||
* doc; the SHAPES of every exported interface/type-alias the service
|
||||
* signatures reference (transitively — so a model can see that e.g. a
|
||||
* `BashRunResult.stdout` is `{ text, truncated }`, not a string); plus the
|
||||
* curated inherited `ctx` surface shared with the docs catalog. Source
|
||||
* pointers are dropped (a `file:line` means nothing to the model) and entries
|
||||
* are sorted deterministically.
|
||||
*
|
||||
* `tsx scripts/gen-cordis-api.ts` → write the artifact
|
||||
* `tsx scripts/gen-cordis-api.ts --check` → exit 1 if the committed file is
|
||||
* stale (CI / pre-push gate)
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { collectEvents, collectServices, INHERITED_SERVICES } from './gen-cordis-catalog.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'packages/cordis/tool-cordis/src/api-catalog.ts'
|
||||
|
||||
/** Declarations longer than this render as a truncated stub — a shape the model cannot skim teaches nothing. */
|
||||
const MAX_DECL_CHARS = 1500
|
||||
|
||||
/** The first sentence of a (possibly multi-line) JSDoc prose block. */
|
||||
function firstSentence(doc: string): string {
|
||||
const line = doc.split('\n', 1)[0] ?? ''
|
||||
const match = /^(.*?[.!?])(?:\s|$)/.exec(line)
|
||||
return (match?.[1] ?? line).trim()
|
||||
}
|
||||
|
||||
/** Render a string as a single-quoted, lint-clean TS literal. */
|
||||
function quote(value: string): string {
|
||||
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, '\\\'').replace(/\n/g, '\\n')}'`
|
||||
}
|
||||
|
||||
/**
|
||||
* Every exported `interface` / `type` declaration under `packages/<group>/<pkg>/src`,
|
||||
* printed without comments, keyed by name. A name declared in more than one
|
||||
* package (e.g. each plugin's `Config`) is ambiguous and dropped entirely —
|
||||
* serving the wrong package's shape is worse than serving none.
|
||||
*/
|
||||
function collectTypeDecls(scanRoot: string = root): Map<string, string> {
|
||||
const printer = ts.createPrinter({ removeComments: true })
|
||||
const decls = new Map<string, string>()
|
||||
const ambiguous = new Set<string>()
|
||||
for (const rel of globSync('packages/*/*/src/*.ts', { cwd: scanRoot }).sort()) {
|
||||
const abs = resolve(scanRoot, rel)
|
||||
const sf = ts.createSourceFile(abs, readFileSync(abs, 'utf8'), ts.ScriptTarget.Latest, true)
|
||||
for (const stmt of sf.statements) {
|
||||
if (!ts.isInterfaceDeclaration(stmt) && !ts.isTypeAliasDeclaration(stmt)) continue
|
||||
if (!(stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false)) continue
|
||||
const name = stmt.name.text
|
||||
if (decls.has(name)) {
|
||||
ambiguous.add(name)
|
||||
continue
|
||||
}
|
||||
const printed = printer.printNode(ts.EmitHint.Unspecified, stmt, sf).replace(/\r/g, '')
|
||||
decls.set(name, printed.length > MAX_DECL_CHARS
|
||||
? `${printed.slice(0, MAX_DECL_CHARS)} /* …truncated — full shape in source */`
|
||||
: printed)
|
||||
}
|
||||
}
|
||||
for (const name of ambiguous) decls.delete(name)
|
||||
return decls
|
||||
}
|
||||
|
||||
/**
|
||||
* The transitive closure of type names referenced by the seed texts: every
|
||||
* collected declaration whose name appears (word-bounded) in a seed or in an
|
||||
* already-included declaration, sorted by name.
|
||||
*/
|
||||
function referencedTypes(seeds: string[], decls: Map<string, string>): { name: string; declaration: string }[] {
|
||||
const included = new Map<string, string>()
|
||||
let frontier = seeds
|
||||
while (frontier.length > 0) {
|
||||
const next: string[] = []
|
||||
for (const [name, declaration] of decls) {
|
||||
if (included.has(name)) continue
|
||||
const pattern = new RegExp(`\\b${name}\\b`)
|
||||
if (frontier.some(text => pattern.test(text))) {
|
||||
included.set(name, declaration)
|
||||
next.push(declaration)
|
||||
}
|
||||
}
|
||||
frontier = next
|
||||
}
|
||||
return [...included].map(([name, declaration]) => ({ name, declaration })).sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
/** Render the whole generated module (pure, deterministic given sorted collector output). */
|
||||
function render(): string {
|
||||
const services = collectServices()
|
||||
const events = collectEvents().sort((a, b) => a.name.localeCompare(b.name))
|
||||
const types = referencedTypes(services.flatMap(service => service.methods), collectTypeDecls())
|
||||
const lines: string[] = [
|
||||
'/**',
|
||||
' * Generated by scripts/gen-cordis-api.ts — do not edit by hand; run',
|
||||
' * `pnpm run gen-cordis-api` to regenerate (freshness-gated by',
|
||||
' * `pnpm run verify-cordis-api` in doc-sync).',
|
||||
' *',
|
||||
' * The machine-readable cordis API catalog `cordis_inspect` serves to the',
|
||||
' * model: harness services (summary + public method signatures), harness',
|
||||
' * events (mode + signature), and the inherited `ctx` surface. Produced by',
|
||||
' * the same AST walk as docs/cordis-catalog, so this data and the rendered',
|
||||
' * docs cannot diverge.',
|
||||
' *',
|
||||
' * @module @deepseek-ai/dsh-tool-cordis/api-catalog',
|
||||
' */',
|
||||
'',
|
||||
'/** One harness `ctx.<key>` service: its one-line summary and public method signatures. */',
|
||||
'export interface ServiceApiEntry {',
|
||||
' /** The `ctx.<key>` name, e.g. `tools`. */',
|
||||
' key: string',
|
||||
' /** First sentence of the service class JSDoc. */',
|
||||
' summary: string',
|
||||
' /** Public method signatures, bodies stripped, in source order. */',
|
||||
' methods: readonly string[]',
|
||||
'}',
|
||||
'',
|
||||
'/** One harness event: its dispatch mode, exact signature, and one-line summary. */',
|
||||
'export interface EventApiEntry {',
|
||||
' /** The scoped event name, e.g. `agent/status`. */',
|
||||
' name: string',
|
||||
' /** The dispatch mode from the declaration\'s `@mode` tag. */',
|
||||
' mode: string',
|
||||
' /** The exact listener signature, whitespace-normalized. */',
|
||||
' signature: string',
|
||||
' /** First sentence of the event JSDoc. */',
|
||||
' summary: string',
|
||||
'}',
|
||||
'',
|
||||
'/** One inherited (cordis core + loader/hmr/timer) `ctx` member group with its summary. */',
|
||||
'export interface InheritedApiEntry {',
|
||||
' /** The `ctx` member name(s), e.g. `ctx.on / ctx.once`. */',
|
||||
' name: string',
|
||||
' /** One-line summary of what the member does. */',
|
||||
' summary: string',
|
||||
'}',
|
||||
'',
|
||||
'/** One named type shape the service signatures reference. */',
|
||||
'export interface TypeApiEntry {',
|
||||
' /** The exported type/interface name, e.g. `BashRunResult`. */',
|
||||
' name: string',
|
||||
' /** The full declaration text, comments stripped. */',
|
||||
' declaration: string',
|
||||
'}',
|
||||
'',
|
||||
'/** Every harness `ctx.<key>` service, sorted by key. */',
|
||||
'export const SERVICE_API: readonly ServiceApiEntry[] = [',
|
||||
]
|
||||
for (const service of services) {
|
||||
lines.push(' {')
|
||||
lines.push(` key: ${quote(service.key)},`)
|
||||
lines.push(` summary: ${quote(firstSentence(service.doc))},`)
|
||||
if (service.methods.length === 0) {
|
||||
lines.push(' methods: [],')
|
||||
} else {
|
||||
lines.push(' methods: [')
|
||||
for (const method of service.methods) lines.push(` ${quote(method)},`)
|
||||
lines.push(' ],')
|
||||
}
|
||||
lines.push(' },')
|
||||
}
|
||||
lines.push(
|
||||
']',
|
||||
'',
|
||||
'/** Every harness event, sorted by name. */',
|
||||
'export const EVENT_API: readonly EventApiEntry[] = [',
|
||||
)
|
||||
for (const event of events) {
|
||||
lines.push(' {')
|
||||
lines.push(` name: ${quote(event.name)},`)
|
||||
lines.push(` mode: ${quote(event.mode)},`)
|
||||
lines.push(` signature: ${quote(event.signature)},`)
|
||||
lines.push(` summary: ${quote(firstSentence(event.doc))},`)
|
||||
lines.push(' },')
|
||||
}
|
||||
lines.push(
|
||||
']',
|
||||
'',
|
||||
'/** Shapes of every exported type the SERVICE_API signatures reference (transitively), sorted by name. */',
|
||||
'export const TYPE_API: readonly TypeApiEntry[] = [',
|
||||
)
|
||||
for (const type of types) {
|
||||
lines.push(' {')
|
||||
lines.push(` name: ${quote(type.name)},`)
|
||||
lines.push(` declaration: ${quote(type.declaration)},`)
|
||||
lines.push(' },')
|
||||
}
|
||||
lines.push(
|
||||
']',
|
||||
'',
|
||||
'/** The inherited `ctx` surface (cordis core + loader/hmr/timer), in curated order. */',
|
||||
'export const INHERITED_CTX_API: readonly InheritedApiEntry[] = [',
|
||||
)
|
||||
for (const inherited of INHERITED_SERVICES) {
|
||||
lines.push(` { name: ${quote(inherited.name)}, summary: ${quote(inherited.summary)} },`)
|
||||
}
|
||||
lines.push(']', '')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/** CLI entry: default writes the artifact, `--check` fails if the committed
|
||||
* copy is stale. Guarded behind an entry-point check so importing this module
|
||||
* for tests neither regenerates the committed file nor calls process.exit. */
|
||||
function main(): void {
|
||||
const content = render()
|
||||
if (process.argv.includes('--check')) {
|
||||
let committed: string | null = null
|
||||
try {
|
||||
committed = readFileSync(resolve(root, OUT), 'utf8')
|
||||
} catch {
|
||||
// Only ENOENT (not yet generated) is expected; a present-but-unreadable
|
||||
// file is not a state this repo produces. Either way the remedy is the
|
||||
// same — regenerate — so treat a read failure as "stale".
|
||||
committed = null
|
||||
}
|
||||
if (committed === content) {
|
||||
console.log(`gen-cordis-api: ${OUT} is up to date.`)
|
||||
process.exit(0)
|
||||
}
|
||||
console.error(`gen-cordis-api: ${OUT} is stale. Run \`pnpm run gen-cordis-api\` and commit ${OUT}.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
writeFileSync(resolve(root, OUT), content)
|
||||
console.log(`gen-cordis-api: wrote ${OUT}.`)
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
@@ -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'
|
||||
@@ -73,11 +73,13 @@ type Mode = 'emit' | 'waterfall' | 'parallel' | 'serial'
|
||||
* that manifest documents the `…Map` symbols (`ContentBlockMap`) while
|
||||
* signatures reference the derived UNION names (`ContentBlock`), and it lists a
|
||||
* few symbols on two pages. Here each name resolves to exactly one PRIMARY page.
|
||||
* Shared with `gen-config-catalog.ts` (each caller prefixes its own relative
|
||||
* path to `core-data-structures/`), so both catalogs cross-link identically.
|
||||
* TODO(catalog-type-links): add a verifier or generator for link-map coverage
|
||||
* so new hook-era decision types like `PromptDecision` / `PreToolDecision` do
|
||||
* not silently appear in signatures without a "Types:" link.
|
||||
*/
|
||||
const LINK_MAP: Record<string, string> = {
|
||||
export const LINK_MAP: Record<string, string> = {
|
||||
Agent: 'core.md',
|
||||
ContentBlock: 'core.md',
|
||||
Message: 'core.md',
|
||||
@@ -95,6 +97,8 @@ const LINK_MAP: Record<string, string> = {
|
||||
BashRunResult: 'bash.md',
|
||||
BashTask: 'bash.md',
|
||||
BashTaskRead: 'bash.md',
|
||||
CodeRunRequest: 'code-runtime.md',
|
||||
CodeRunResult: 'code-runtime.md',
|
||||
FsEditOutcome: 'filesystem.md',
|
||||
FsEditRequest: 'filesystem.md',
|
||||
FsInfo: 'filesystem.md',
|
||||
@@ -146,132 +150,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 +212,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 +279,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 +296,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))
|
||||
}
|
||||
|
||||
@@ -486,7 +327,7 @@ const INHERITED_EVENTS: InheritedEntry[] = [
|
||||
{ name: 'loader/patch-context', summary: 'A context is being patched during a reload.', source: 'vendor/loader/src/index.ts:27' },
|
||||
]
|
||||
|
||||
const INHERITED_SERVICES: InheritedEntry[] = [
|
||||
export const INHERITED_SERVICES: InheritedEntry[] = [
|
||||
{ name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:29' },
|
||||
{ name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / veto-chain).', source: 'vendor/cordis/src/events.ts:29' },
|
||||
{ name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:144' },
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* This is the relationship layer above the existing catalogs:
|
||||
* - module-graph.md answers "which packages depend on which packages?"
|
||||
* - cordis-catalog/ answers "which events and services exist?"
|
||||
* - tool-catalog/ answers "which tools does the model see?"
|
||||
* - tool-catalog.md answers "which tools does the model see?"
|
||||
* - generated relationship diagrams answer "how do those pieces fit together?"
|
||||
*
|
||||
* Generated pages discover the enumerable facts from source. Hybrid pages use
|
||||
@@ -75,6 +75,7 @@ const GROUP_ORDER = [
|
||||
'subagent',
|
||||
'web',
|
||||
'todo',
|
||||
'cordis',
|
||||
'hooks',
|
||||
'session-persistence',
|
||||
'support',
|
||||
@@ -121,9 +122,18 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
pkg: 'tools',
|
||||
title: 'Tool registry and execution waterfall',
|
||||
mode: 'core',
|
||||
consumers: ['agent-loop', 'tool-bash', 'tool-fs', 'tool-skill', 'tool-subagent', 'tool-todo', 'tool-web', 'acp'],
|
||||
consumers: ['agent-loop', 'tool-ask-user', 'tool-bash', 'tool-cordis', 'tool-fs', 'tool-skill', 'tool-subagent', 'tool-todo', 'tool-web', 'acp'],
|
||||
note: 'Registers tool definitions, exposes schemas to the prompt, and routes calls through tools/pre-execute and tools/post-execute.',
|
||||
},
|
||||
{
|
||||
key: 'userInteraction',
|
||||
pkg: 'user-interaction',
|
||||
title: 'Human question/answer seam',
|
||||
mode: 'seam',
|
||||
implementations: ['stdio-agent', 'acp'],
|
||||
consumers: ['tool-ask-user', 'stdio-agent', 'acp'],
|
||||
note: 'UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.',
|
||||
},
|
||||
{
|
||||
key: 'skills',
|
||||
pkg: 'skill',
|
||||
@@ -157,6 +167,15 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
consumers: ['tool-bash', 'hooks-claude', 'hooks-codex'],
|
||||
note: 'The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors can replace bash-local.',
|
||||
},
|
||||
{
|
||||
key: 'codeRuntime',
|
||||
pkg: 'code-runtime',
|
||||
title: 'Code-execution seam',
|
||||
mode: 'seam',
|
||||
implementations: ['code-runtime-worker'],
|
||||
consumers: [],
|
||||
note: 'Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the Code Mode RFC specifies the worker-thread backend and the tool-registry consumer).',
|
||||
},
|
||||
{
|
||||
key: 'fs',
|
||||
pkg: 'fs',
|
||||
@@ -408,6 +427,14 @@ const APP_EXAMPLES = [
|
||||
config: 'examples/coding-agent/cordis.yml',
|
||||
summary: 'The coding REPL demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package.',
|
||||
},
|
||||
{
|
||||
id: 'cordis',
|
||||
rel: 'examples/cordis-agent/composition.md',
|
||||
title: 'Cordis Agent App Composition',
|
||||
label: 'examples/cordis-agent',
|
||||
config: 'examples/cordis-agent/cordis.yml',
|
||||
summary: 'The self-referential demo puts @deepseek-ai/dsh-tool-cordis on the coding spine, letting the agent inspect its own runtime and mount/unmount plugins into it.',
|
||||
},
|
||||
{
|
||||
id: 'acp',
|
||||
rel: 'examples/acp-agent/composition.md',
|
||||
@@ -629,7 +656,7 @@ function renderToolPipeline(): string {
|
||||
const maintenance = 'curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs'
|
||||
return [
|
||||
...generatedHeader('Tool Execution Pipeline'),
|
||||
'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, and UI rendering fit without changing the loop. The key extension points are the `tools/pre-execute` and `tools/post-execute` waterfalls.',
|
||||
'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, and UI rendering fit without changing the loop. The key extension points are the `tools/pre-execute`, `tools/execute`, and `tools/post-execute` waterfalls.',
|
||||
'',
|
||||
'```mermaid',
|
||||
'flowchart TD',
|
||||
@@ -638,6 +665,7 @@ function renderToolPipeline(): string {
|
||||
' presentCall["UI pending card<br/>presentCall(args)"]',
|
||||
` pre["${mermaidCode('tools/pre-execute')} waterfall<br/>hooks, permission, sandbox"]`,
|
||||
' denied["deny or ask<br/>tool body skipped"]',
|
||||
` around["${mermaidCode('tools/execute')} waterfall<br/>timeout, retry, metrics (around dispatch)"]`,
|
||||
' toolBody["Registered tool execute() body"]',
|
||||
` fsGate["${mermaidCode('fs/write-intent')} or ${mermaidCode('fs/edit-intent')}<br/>tool-fs mutations only"]`,
|
||||
` owned["Tool-owned session events<br/>${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}"]`,
|
||||
@@ -648,19 +676,21 @@ function renderToolPipeline(): string {
|
||||
' model --> toolCall',
|
||||
' toolCall --> presentCall',
|
||||
' toolCall --> pre',
|
||||
' pre -->|allow| toolBody',
|
||||
' pre -->|allow| around',
|
||||
' around --> toolBody',
|
||||
' pre -->|deny or ask| denied',
|
||||
' denied --> post',
|
||||
' toolBody --> fsGate',
|
||||
' fsGate --> toolBody',
|
||||
' toolBody --> owned',
|
||||
' toolBody --> post',
|
||||
' toolBody --> around',
|
||||
' around --> post',
|
||||
' post --> context',
|
||||
' post --> toolResult',
|
||||
' toolResult --> presentResult',
|
||||
'```',
|
||||
'',
|
||||
'Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate, while hook bridges and future permission prompts live on the generic tool waterfalls. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service.',
|
||||
'Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and future permission prompts live on the generic pre/post tool waterfalls; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service.',
|
||||
'',
|
||||
...maintenanceFooter(maintenance),
|
||||
].join('\n')
|
||||
@@ -714,6 +744,7 @@ function renderIndex(docs: GraphDoc[]): string {
|
||||
'docs/capability-seams.md': 'capability seams and core services',
|
||||
'examples/echo-agent/composition.md': 'echo-agent app composition',
|
||||
'examples/coding-agent/composition.md': 'coding-agent app composition',
|
||||
'examples/cordis-agent/composition.md': 'cordis-agent app composition',
|
||||
'examples/acp-agent/composition.md': 'acp-agent app composition',
|
||||
'docs/event-producer-consumer.md': 'event producer/consumer matrix',
|
||||
'docs/agent-lifecycle.md': 'agent turn and step lifecycle',
|
||||
@@ -724,6 +755,7 @@ function renderIndex(docs: GraphDoc[]): string {
|
||||
'docs/capability-seams.md': 'hybrid generated',
|
||||
'examples/echo-agent/composition.md': 'hybrid generated',
|
||||
'examples/coding-agent/composition.md': 'hybrid generated',
|
||||
'examples/cordis-agent/composition.md': 'hybrid generated',
|
||||
'examples/acp-agent/composition.md': 'hybrid generated',
|
||||
'docs/event-producer-consumer.md': 'hybrid generated',
|
||||
'docs/agent-lifecycle.md': 'curated',
|
||||
@@ -732,7 +764,7 @@ function renderIndex(docs: GraphDoc[]): string {
|
||||
}
|
||||
const rows = [
|
||||
'| [module dependency graph](module-graph.md) | `generated` |',
|
||||
'| [tool schema catalog and package map](tool-catalog/tools.md) | `generated` |',
|
||||
'| [tool schema catalog and package map](tool-catalog.md) | `generated` |',
|
||||
...docs.map((doc) => {
|
||||
const link = graphIndexLink(doc.rel)
|
||||
return `| [${labels[doc.rel] ?? link}](${link}) | \`${modes[doc.rel] ?? 'generated'}\` |`
|
||||
@@ -741,7 +773,7 @@ function renderIndex(docs: GraphDoc[]): string {
|
||||
const maintenance = 'mixed: each linked page declares generated, hybrid, or curated mode'
|
||||
return [
|
||||
...generatedHeader('Documentation Graph Index'),
|
||||
'These diagrams are the relationship layer above the generated catalogs. Use them to navigate package topology, capability seams, event flow, model-facing tools, app composition, and runtime lifecycle paths. Exact signatures and type shapes still live in the generated [events](cordis-catalog/events.md) / [services](cordis-catalog/services.md) catalogs, [tool-catalog/](tool-catalog/tools.md), and [core-data-structures/](core-data-structures/core.md).',
|
||||
'These diagrams are the relationship layer above the generated catalogs. Use them to navigate package topology, capability seams, event flow, model-facing tools, app composition, and runtime lifecycle paths. Exact signatures and type shapes still live in the generated [events](cordis-catalog/events.md) / [services](cordis-catalog/services.md) catalogs, [tool-catalog.md](tool-catalog.md), and [core-data-structures/](core-data-structures/core.md).',
|
||||
'',
|
||||
'The process decision behind this index is recorded in [the documentation graph RFC](rfc/implemented/process/2026-07-03-documentation-graph-atlas.md).',
|
||||
'',
|
||||
|
||||
@@ -45,7 +45,9 @@ const GROUP_ORDER = [
|
||||
'compact',
|
||||
'subagent',
|
||||
'web',
|
||||
'timeout',
|
||||
'todo',
|
||||
'cordis',
|
||||
'hooks',
|
||||
'session-persistence',
|
||||
'support',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Generate (and verify) the persistence log event catalog in
|
||||
* docs/persistence-catalog/log-events.md.
|
||||
* docs/persistence-catalog.md.
|
||||
*
|
||||
* The catalog is the ON-DISK-vocabulary reference: every event type that can
|
||||
* appear in a session's durable event log — every member of the
|
||||
@@ -47,7 +47,7 @@ import { resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'docs/persistence-catalog/log-events.md'
|
||||
const OUT = 'docs/persistence-catalog.md'
|
||||
|
||||
/** The fenced-block info string for generated payload blocks (skipped by
|
||||
* doc-typecheck, since a bare payload fragment is not standalone-compilable). */
|
||||
@@ -381,7 +381,7 @@ function typeLinks(payload: string): string {
|
||||
if (new RegExp(`\\b${name}\\b`).test(payload)) seen.add(name)
|
||||
}
|
||||
if (seen.size === 0) return ''
|
||||
const links = [...seen].sort().map(n => `[${n}](../core-data-structures/${LINK_MAP[n]})`)
|
||||
const links = [...seen].sort().map(n => `[${n}](core-data-structures/${LINK_MAP[n]})`)
|
||||
return `Types: ${links.join(' · ')}`
|
||||
}
|
||||
|
||||
@@ -392,7 +392,7 @@ function renderEvent(e: AnnotatedLogEventEntry): string[] {
|
||||
out.push('```' + FENCE, `'${e.name}': ${e.payload}`, '```', '')
|
||||
const links = typeLinks(e.payload)
|
||||
if (links) out.push(links, '')
|
||||
out.push(`Source: [\`${e.source}\`](../../${e.source.split(':')[0]})`, '')
|
||||
out.push(`Source: [\`${e.source}\`](../${e.source.split(':')[0]})`, '')
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -404,11 +404,11 @@ export function render(events: AnnotatedLogEventEntry[]): string {
|
||||
'',
|
||||
'# Persistence Log Event Catalog',
|
||||
'',
|
||||
'Every event type that can appear in a session\'s durable event log: each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with the payload it carries, its surface badge, and the declaration it comes from. It complements [session.md](../core-data-structures/session.md) (the `SessionEvent` envelope, surface list, and `deriveMessages()` projection), [persistence.md](../core-data-structures/persistence.md) (how the log is made durable), and the [cordis events catalog](../cordis-catalog/events.md) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).',
|
||||
'Every event type that can appear in a session\'s durable event log: each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with the payload it carries, its surface badge, and the declaration it comes from. It complements [session.md](core-data-structures/session.md) (the `SessionEvent` envelope, surface list, and `deriveMessages()` projection), [persistence.md](core-data-structures/persistence.md) (how the log is made durable), and the [cordis events catalog](cordis-catalog/events.md) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).',
|
||||
'',
|
||||
'This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Payload blocks use a `ts persistence-catalog` fence (skipped by doc-typecheck, since a bare payload fragment is not standalone-compilable). Type names in a payload link to the page that documents them. See [the persistence-log-catalog RFC](../rfc/implemented/process/2026-07-04-persistence-log-catalog.md).',
|
||||
'This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Payload blocks use a `ts persistence-catalog` fence (skipped by doc-typecheck, since a bare payload fragment is not standalone-compilable). Type names in a payload link to the page that documents them. See [the persistence-log-catalog RFC](rfc/implemented/process/2026-07-04-persistence-log-catalog.md).',
|
||||
'',
|
||||
'The on-disk envelope around every payload is `SessionEvent` — `type`, monotonic `seq`, epoch-ms `time`, the `data` documented here, plus `surfaceOp`/`sourceEventSeqs` on **surface** events only ([envelope](../core-data-structures/session.md#sessioneventt--one-log-entry)). **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](../core-data-structures/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.',
|
||||
'The on-disk envelope around every payload is `SessionEvent` — `type`, monotonic `seq`, epoch-ms `time`, the `data` documented here, plus `surfaceOp`/`sourceEventSeqs` on **surface** events only ([envelope](core-data-structures/session.md#sessioneventt--one-log-entry)). **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](core-data-structures/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.',
|
||||
'',
|
||||
'## Events',
|
||||
'',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Generate (and verify) the tool-schema catalog in docs/tool-catalog/tools.md.
|
||||
* Generate (and verify) the tool-schema catalog in docs/tool-catalog.md.
|
||||
*
|
||||
* The catalog is the MODEL-FACING TOOL reference: every tool a shipped plugin
|
||||
* contributes to `ctx.tools`, with the exact `name` / `description` / JSON-Schema
|
||||
@@ -41,6 +41,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import WebService from '@deepseek-ai/dsh-web'
|
||||
import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa'
|
||||
import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local'
|
||||
@@ -48,7 +49,9 @@ import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import * as SubagentMock from '@deepseek-ai/dsh-subagent-mock'
|
||||
import SkillService from '@deepseek-ai/dsh-skill'
|
||||
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
|
||||
import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import * as ToolSkill from '@deepseek-ai/dsh-tool-skill'
|
||||
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
|
||||
@@ -56,7 +59,7 @@ import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent'
|
||||
import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'docs/tool-catalog/tools.md'
|
||||
const OUT = 'docs/tool-catalog.md'
|
||||
|
||||
/**
|
||||
* One tool-plugin package to boot. `mount` is a per-entry recipe (async): it
|
||||
@@ -103,6 +106,19 @@ interface ToolPackage {
|
||||
* guard proves it is exhaustive against the on-disk glob.
|
||||
*/
|
||||
const TOOL_PACKAGES: ToolPackage[] = [
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-ask-user',
|
||||
dir: 'tool-ask-user',
|
||||
source: 'packages/ui/tool-ask-user/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.userInteraction'],
|
||||
writes: ['tool/call', 'tool/result after a UI/provider answers the question'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(ToolAskUser)
|
||||
},
|
||||
note:
|
||||
'ask_user_question pauses the tool call until the active UI provider returns a human answer.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-bash',
|
||||
dir: 'tool-bash',
|
||||
@@ -116,6 +132,18 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
note:
|
||||
'The bash/bash_output/bash_kill tools are model-facing consumers of the bash executor seam.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-cordis',
|
||||
dir: 'tool-cordis',
|
||||
source: 'packages/cordis/tool-cordis/src/index.ts',
|
||||
requires: ['ctx.tools'],
|
||||
writes: ['tool/call', 'tool/result', 'live plugin-tree mutations (mount/unmount)'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(ToolCordis)
|
||||
},
|
||||
note:
|
||||
'Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; the request-header ToolsDelta logs those tool-set changes.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-fs',
|
||||
dir: 'tool-fs',
|
||||
@@ -273,7 +301,7 @@ function renderTool(schema: ToolSchema, source: string): string[] {
|
||||
const out = [`### \`${schema.name}\``, '']
|
||||
if (schema.description) out.push(schema.description, '')
|
||||
out.push('```json', JSON.stringify(schema.parameters, null, 2), '```', '')
|
||||
out.push(`Source: [\`${source}\`](../../${source})`, '')
|
||||
out.push(`Source: [\`${source}\`](../${source})`, '')
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -293,9 +321,9 @@ export function render(catalog: ToolCatalog): string {
|
||||
'',
|
||||
'# Tool Schema Catalog',
|
||||
'',
|
||||
'Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the cordis [events](../cordis-catalog/events.md) & [services](../cordis-catalog/services.md) catalogs (the wiring a plugin listens to and calls) and [core-data-structures/](../core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered.',
|
||||
'Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the cordis [events](cordis-catalog/events.md) & [services](cordis-catalog/services.md) catalogs (the wiring a plugin listens to and calls) and [core-data-structures/](core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered.',
|
||||
'',
|
||||
'This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator\'s boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog RFC](../rfc/implemented/process/2026-07-02-tool-schema-catalog.md).',
|
||||
'This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator\'s boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog RFC](rfc/implemented/process/2026-07-02-tool-schema-catalog.md).',
|
||||
'',
|
||||
'Scope: shipped product tools under `packages/*/tool-*`, each booted with its DEFAULT config. The registered tool NAME can be a load-time config (e.g. `tool-subagent`\'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog\'s packages-only scope.',
|
||||
'',
|
||||
|
||||
218
scripts/jsdoc.ts
Normal file
218
scripts/jsdoc.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* 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), the plugin config catalog generator
|
||||
* (`scripts/gen-config-catalog.ts`, which renders the parsed prose), 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'),
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { existsSync, readdirSync } from 'node:fs'
|
||||
import { availableParallelism } from 'node:os'
|
||||
import { resolve } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY'
|
||||
|
||||
// publint every harness package. Packages live at packages/<group>/<pkg>
|
||||
// (the group dirs — core/llm/bash/… — are pure containers); vendor/ is private
|
||||
@@ -9,15 +14,94 @@ import { resolve } from 'node:path'
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const packagesRoot = resolve(root, 'packages')
|
||||
|
||||
const packages = readdirSync(packagesRoot, { withFileTypes: true })
|
||||
.filter(group => group.isDirectory())
|
||||
.flatMap(group =>
|
||||
readdirSync(resolve(packagesRoot, group.name), { withFileTypes: true })
|
||||
.filter(pkg => pkg.isDirectory())
|
||||
.filter(pkg => existsSync(resolve(packagesRoot, group.name, pkg.name, 'package.json')))
|
||||
.map(pkg => `packages/${group.name}/${pkg.name}`),
|
||||
)
|
||||
type PublintResult =
|
||||
| { path: string; status: 'passed'; stdout: string; stderr: string }
|
||||
| { path: string; status: 'failed'; stdout: string; stderr: string; message: string }
|
||||
|
||||
for (const path of packages) {
|
||||
execFileSync('node_modules/.bin/publint', [path], { cwd: root, stdio: 'inherit' })
|
||||
function workspacePackages(): string[] {
|
||||
return readdirSync(packagesRoot, { withFileTypes: true })
|
||||
.filter(group => group.isDirectory())
|
||||
.flatMap(group =>
|
||||
readdirSync(resolve(packagesRoot, group.name), { withFileTypes: true })
|
||||
.filter(pkg => pkg.isDirectory())
|
||||
.filter(pkg => existsSync(resolve(packagesRoot, group.name, pkg.name, 'package.json')))
|
||||
.map(pkg => `packages/${group.name}/${pkg.name}`),
|
||||
)
|
||||
}
|
||||
|
||||
function publintConcurrency(total: number): number {
|
||||
if (total === 0) return 0
|
||||
|
||||
const raw = process.env[CONCURRENCY_ENV]
|
||||
if (raw !== undefined) {
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
||||
throw new Error(`publint-all: ${CONCURRENCY_ENV} must be a positive integer, got ${JSON.stringify(raw)}.`)
|
||||
}
|
||||
return Math.min(total, parsed)
|
||||
}
|
||||
|
||||
return Math.min(total, availableParallelism())
|
||||
}
|
||||
|
||||
function outputText(value: unknown): string {
|
||||
if (typeof value === 'string') return value
|
||||
if (Buffer.isBuffer(value)) return value.toString()
|
||||
return ''
|
||||
}
|
||||
|
||||
async function runPublint(path: string): Promise<PublintResult> {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync('node_modules/.bin/publint', [path], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
})
|
||||
return { path, status: 'passed', stdout, stderr }
|
||||
} catch (error: unknown) {
|
||||
const failed = error as { stdout?: unknown; stderr?: unknown; message?: string }
|
||||
return {
|
||||
path,
|
||||
status: 'failed',
|
||||
stdout: outputText(failed.stdout),
|
||||
stderr: outputText(failed.stderr),
|
||||
message: failed.message ?? 'publint failed',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runAll(paths: string[], concurrency: number): Promise<PublintResult[]> {
|
||||
let next = 0
|
||||
const results: Array<PublintResult | undefined> = []
|
||||
await Promise.all(Array.from({ length: concurrency }, async () => {
|
||||
for (;;) {
|
||||
const index = next
|
||||
next += 1
|
||||
const path = paths[index]
|
||||
if (path === undefined) return
|
||||
results[index] = await runPublint(path)
|
||||
}
|
||||
}))
|
||||
|
||||
return paths.map((path, index) => {
|
||||
const result = results[index]
|
||||
if (result === undefined) throw new Error(`publint-all: missing result for ${path}.`)
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
function printResult(result: PublintResult): void {
|
||||
console.log(`Running publint for ${result.path}...`)
|
||||
process.stdout.write(result.stdout)
|
||||
process.stderr.write(result.stderr)
|
||||
if (result.status === 'failed') console.error(result.message)
|
||||
}
|
||||
|
||||
const packages = workspacePackages()
|
||||
const concurrency = publintConcurrency(packages.length)
|
||||
console.log(`publint-all: linting ${packages.length} package(s) with ${concurrency} worker(s).`)
|
||||
|
||||
const results = await runAll(packages, concurrency)
|
||||
for (const result of results) printResult(result)
|
||||
|
||||
if (results.some(result => result.status === 'failed')) process.exit(1)
|
||||
|
||||
434
scripts/run-gates.ts
Normal file
434
scripts/run-gates.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* Run local and CI quality gates with bounded in-process scheduling.
|
||||
*
|
||||
* The gate vocabulary stays in package.json; this runner only decides which
|
||||
* independent commands can overlap and which commands wait for built artifacts.
|
||||
*/
|
||||
import { spawn } from 'node:child_process'
|
||||
import { readdir, rm } from 'node:fs/promises'
|
||||
import { availableParallelism } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
|
||||
type Mode =
|
||||
| 'ci-primary'
|
||||
| 'ci-static'
|
||||
| 'ci-lint'
|
||||
| 'ci-coverage'
|
||||
| 'ci-snapshot'
|
||||
| 'ci-artifacts'
|
||||
| 'node-compat'
|
||||
| 'pre-push'
|
||||
type GateStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped'
|
||||
|
||||
interface Gate {
|
||||
id: string
|
||||
label: string
|
||||
command: string
|
||||
args: string[]
|
||||
needs?: string[]
|
||||
env?: Record<string, string | undefined>
|
||||
input?: string
|
||||
verify?: (result: GateResult) => Promise<void>
|
||||
}
|
||||
|
||||
interface GateResult {
|
||||
gate: Gate
|
||||
status: GateStatus
|
||||
durationMs: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
exitCode: number | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface RunningGate {
|
||||
gate: Gate
|
||||
promise: Promise<GateResult>
|
||||
}
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const mode = parseMode(process.argv[2])
|
||||
const gates = gatesForMode(mode)
|
||||
const maxConcurrency = concurrencyFromEnv('DSH_GATE_CONCURRENCY', defaultConcurrency(gates.length))
|
||||
const startedAt = performance.now()
|
||||
|
||||
console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcurrency} worker(s).`)
|
||||
|
||||
const results = await runGates(gates, maxConcurrency)
|
||||
printSummary(results, performance.now() - startedAt)
|
||||
|
||||
if (results.some(result => result.status === 'failed' || result.status === 'skipped')) process.exit(1)
|
||||
|
||||
function parseMode(raw: string | undefined): Mode {
|
||||
switch (raw) {
|
||||
case 'ci-primary':
|
||||
case 'ci-static':
|
||||
case 'ci-lint':
|
||||
case 'ci-coverage':
|
||||
case 'ci-snapshot':
|
||||
case 'ci-artifacts':
|
||||
case 'node-compat':
|
||||
case 'pre-push':
|
||||
return raw
|
||||
default:
|
||||
throw new Error(
|
||||
`run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | node-compat | pre-push, got ${JSON.stringify(raw)}.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function defaultConcurrency(total: number): number {
|
||||
return Math.min(total, Math.max(4, availableParallelism()))
|
||||
}
|
||||
|
||||
function concurrencyFromEnv(name: string, fallback: number): number {
|
||||
const raw = process.env[name]
|
||||
if (raw === undefined || raw === '') return fallback
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
||||
throw new Error(`run-gates: ${name} must be a positive integer, got ${JSON.stringify(raw)}.`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function pnpmScript(id: string, script: string, options: Partial<Gate> = {}): Gate {
|
||||
return {
|
||||
id,
|
||||
label: options.label ?? script,
|
||||
command: pnpmBin(),
|
||||
args: ['run', script],
|
||||
...options,
|
||||
}
|
||||
}
|
||||
|
||||
function pnpmExec(id: string, args: string[], options: Partial<Gate> = {}): Gate {
|
||||
return {
|
||||
id,
|
||||
label: options.label ?? `pnpm exec ${args.join(' ')}`,
|
||||
command: pnpmBin(),
|
||||
args: ['exec', ...args],
|
||||
...options,
|
||||
}
|
||||
}
|
||||
|
||||
function pnpmBin(): string {
|
||||
return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
}
|
||||
|
||||
function nodeOptions(...options: string[]): string {
|
||||
return [process.env.NODE_OPTIONS, ...options].filter(option => option !== undefined && option !== '').join(' ')
|
||||
}
|
||||
|
||||
function gatesForMode(selected: Mode): Gate[] {
|
||||
switch (selected) {
|
||||
case 'ci-primary':
|
||||
return ciPrimaryGates()
|
||||
case 'ci-static':
|
||||
return ciStaticGates()
|
||||
case 'ci-lint':
|
||||
return [
|
||||
lintGate(),
|
||||
]
|
||||
case 'ci-coverage':
|
||||
return [
|
||||
coverageGate(),
|
||||
]
|
||||
case 'ci-snapshot':
|
||||
return [
|
||||
pnpmScript('snapshot', 'test:snapshot'),
|
||||
]
|
||||
case 'ci-artifacts':
|
||||
return ciArtifactGates()
|
||||
case 'node-compat':
|
||||
return [
|
||||
pnpmScript('typecheck', 'typecheck'),
|
||||
]
|
||||
case 'pre-push':
|
||||
return [
|
||||
pnpmScript('test', 'test'),
|
||||
pnpmScript('snapshot', 'test:snapshot'),
|
||||
pnpmScript('build', 'build'),
|
||||
...hygieneLeafGates({ artifactNeeds: ['build'] }),
|
||||
...docSyncLeafGates(),
|
||||
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function ciPrimaryGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
pnpmScript('typecheck', 'typecheck'),
|
||||
lintGate(),
|
||||
coverageGate(),
|
||||
pnpmScript('snapshot', 'test:snapshot'),
|
||||
demoSmokeGate({ needs: ['lint'] }),
|
||||
...docSyncLeafGates(),
|
||||
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
|
||||
pnpmScript('knip', 'knip'),
|
||||
pnpmScript('build', 'build', { needs: ['typecheck'] }),
|
||||
pnpmScript('publint', 'publint', { needs: ['build'] }),
|
||||
pnpmScript('node-next-types', 'verify-node-next-types', {
|
||||
label: 'node-next types',
|
||||
needs: ['build'],
|
||||
}),
|
||||
builtBinSmokeGate(),
|
||||
]
|
||||
}
|
||||
|
||||
function ciStaticGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
demoSmokeGate(),
|
||||
...docSyncLeafGates(),
|
||||
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
|
||||
pnpmScript('knip', 'knip'),
|
||||
]
|
||||
}
|
||||
|
||||
function ciArtifactGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('build', 'build'),
|
||||
pnpmScript('publint', 'publint', { needs: ['build'] }),
|
||||
pnpmScript('node-next-types', 'verify-node-next-types', {
|
||||
label: 'node-next types',
|
||||
needs: ['build'],
|
||||
}),
|
||||
builtBinSmokeGate(),
|
||||
]
|
||||
}
|
||||
|
||||
function lintGate(): Gate {
|
||||
if (process.env.DSH_ESLINT_CACHE === '1') {
|
||||
return pnpmExec('lint', [
|
||||
'eslint',
|
||||
'.',
|
||||
'--cache',
|
||||
'--cache-location',
|
||||
'.cache/eslint/',
|
||||
'--cache-strategy',
|
||||
'content',
|
||||
], {
|
||||
label: 'lint',
|
||||
env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') },
|
||||
})
|
||||
}
|
||||
return pnpmScript('lint', 'lint', {
|
||||
env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') },
|
||||
})
|
||||
}
|
||||
|
||||
function coverageGate(): Gate {
|
||||
return pnpmExec('coverage', [
|
||||
'vitest',
|
||||
'run',
|
||||
'--coverage',
|
||||
...positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers'),
|
||||
], {
|
||||
label: 'test:coverage',
|
||||
})
|
||||
}
|
||||
|
||||
function positiveIntArg(envName: string, flag: string): string[] {
|
||||
const raw = process.env[envName]
|
||||
if (raw === undefined || raw === '') return []
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) {
|
||||
throw new Error(`run-gates: ${envName} must be a positive integer, got ${JSON.stringify(raw)}.`)
|
||||
}
|
||||
return [`${flag}=${raw}`]
|
||||
}
|
||||
|
||||
function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
|
||||
const artifactOptions = options.artifactNeeds === undefined ? {} : { needs: options.artifactNeeds }
|
||||
return [
|
||||
pnpmScript('knip', 'knip'),
|
||||
pnpmScript('publint', 'publint', artifactOptions),
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
pnpmScript('node-next-types', 'verify-node-next-types', {
|
||||
label: 'node-next types',
|
||||
...artifactOptions,
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
function docSyncLeafGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('doc-typecheck', 'doc-typecheck'),
|
||||
pnpmScript('cordis-catalog', 'verify-cordis-catalog', { label: 'cordis catalog' }),
|
||||
pnpmScript('export-jsdoc', 'verify-export-jsdoc', { label: 'export jsdoc' }),
|
||||
pnpmScript('tool-catalog', 'verify-tool-catalog', { label: 'tool catalog' }),
|
||||
pnpmScript('config-catalog', 'verify-config-catalog', { label: 'config catalog' }),
|
||||
pnpmScript('persistence-catalog', 'verify-persistence-catalog', { label: 'persistence catalog' }),
|
||||
pnpmScript('doc-graphs', 'verify-doc-graphs', { label: 'doc graphs' }),
|
||||
pnpmScript('markdown-wrap', 'verify-md-wrap', { label: 'markdown wrap' }),
|
||||
pnpmScript('markdown-links', 'verify-md-links', { label: 'markdown links' }),
|
||||
pnpmScript('doc-refs', 'verify-doc-refs', { label: 'doc refs' }),
|
||||
pnpmScript('package-paths', 'verify-package-paths', { label: 'package paths' }),
|
||||
pnpmScript('mermaid', 'verify-mermaid'),
|
||||
pnpmScript('rfc-classification', 'verify-rfc-classification', { label: 'rfc classification' }),
|
||||
pnpmScript('rfc-format', 'verify-rfc-format', { label: 'rfc format' }),
|
||||
pnpmScript('type-equivalence', 'verify-type-equiv', { label: 'type equivalence' }),
|
||||
pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }),
|
||||
pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }),
|
||||
]
|
||||
}
|
||||
|
||||
function demoSmokeGate(options: { needs?: string[] } = {}): Gate {
|
||||
const dependencyOptions = options.needs === undefined ? {} : { needs: options.needs }
|
||||
return {
|
||||
id: 'demo-smoke',
|
||||
label: 'demo smoke',
|
||||
command: pnpmBin(),
|
||||
args: ['run', 'demo:echo'],
|
||||
input: 'echo ci smoke\n',
|
||||
...dependencyOptions,
|
||||
verify: async (result) => {
|
||||
const output = result.stdout + result.stderr
|
||||
if (!output.includes('[tool call] echo({"text":"ci smoke"})')) {
|
||||
throw new Error('demo smoke did not show the echo tool call.')
|
||||
}
|
||||
if (!output.includes('[tool result] ECHO: CI SMOKE')) {
|
||||
throw new Error('demo smoke did not show the echo tool result.')
|
||||
}
|
||||
const sessionDir = join(root, '.sessions', '_no-cwd')
|
||||
const entries = await readdir(sessionDir)
|
||||
if (!entries.some(entry => /^main-session-.+\.jsonl$/.test(entry))) {
|
||||
throw new Error('demo smoke did not create a main-session JSONL log.')
|
||||
}
|
||||
await rm(join(root, '.sessions'), { recursive: true, force: true })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function builtBinSmokeGate(): Gate {
|
||||
return pnpmExec('built-bin-smoke', [
|
||||
'vitest',
|
||||
'run',
|
||||
'--config',
|
||||
'vitest.e2e.config.ts',
|
||||
'packages/ui/stdio-agent/tests/built-bin.e2e.ts',
|
||||
'packages/ui/acp-agent/tests/built-bin.e2e.ts',
|
||||
'packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts',
|
||||
], {
|
||||
label: 'built-bin smoke',
|
||||
needs: ['build'],
|
||||
})
|
||||
}
|
||||
|
||||
async function runGates(allGates: Gate[], maxActive: number): Promise<GateResult[]> {
|
||||
const states = new Map<string, GateStatus>(allGates.map(gate => [gate.id, 'pending']))
|
||||
const results = new Map<string, GateResult>()
|
||||
const running: RunningGate[] = []
|
||||
|
||||
for (;;) {
|
||||
let madeProgress = false
|
||||
while (running.length < maxActive) {
|
||||
const ready = allGates.find(gate => states.get(gate.id) === 'pending' && dependenciesPassed(gate, states))
|
||||
if (ready === undefined) break
|
||||
states.set(ready.id, 'running')
|
||||
running.push({ gate: ready, promise: runGate(ready) })
|
||||
console.log(`run-gates: start ${ready.label}`)
|
||||
madeProgress = true
|
||||
}
|
||||
|
||||
if (running.length === 0) {
|
||||
const pending = allGates.filter(gate => states.get(gate.id) === 'pending')
|
||||
for (const gate of pending) {
|
||||
const failedDeps = (gate.needs ?? []).filter(id => states.get(id) !== 'passed')
|
||||
const result: GateResult = {
|
||||
gate,
|
||||
status: 'skipped',
|
||||
durationMs: 0,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: null,
|
||||
error: `dependency failed or skipped: ${failedDeps.join(', ')}`,
|
||||
}
|
||||
states.set(gate.id, 'skipped')
|
||||
results.set(gate.id, result)
|
||||
printResult(result)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if (!madeProgress) {
|
||||
const settled = await Promise.race(running.map(async item => ({ item, result: await item.promise })))
|
||||
running.splice(running.indexOf(settled.item), 1)
|
||||
states.set(settled.item.gate.id, settled.result.status)
|
||||
results.set(settled.item.gate.id, settled.result)
|
||||
printResult(settled.result)
|
||||
}
|
||||
}
|
||||
|
||||
return allGates.map((gate) => {
|
||||
const result = results.get(gate.id)
|
||||
if (result === undefined) throw new Error(`run-gates: missing result for ${gate.id}.`)
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
function dependenciesPassed(gate: Gate, states: Map<string, GateStatus>): boolean {
|
||||
return (gate.needs ?? []).every(id => states.get(id) === 'passed')
|
||||
}
|
||||
|
||||
async function runGate(gate: Gate): Promise<GateResult> {
|
||||
const started = performance.now()
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
|
||||
const exitCode = await new Promise<number | null>((resolveExit, reject) => {
|
||||
const child = spawn(gate.command, gate.args, {
|
||||
cwd: root,
|
||||
env: { ...process.env, ...gate.env },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stdout.on('data', (chunk: string) => { stdout += chunk })
|
||||
child.stderr.on('data', (chunk: string) => { stderr += chunk })
|
||||
child.on('error', reject)
|
||||
child.on('close', resolveExit)
|
||||
if (gate.input !== undefined) child.stdin.end(gate.input)
|
||||
else child.stdin.end()
|
||||
})
|
||||
|
||||
let status: GateStatus = exitCode === 0 ? 'passed' : 'failed'
|
||||
let error: string | undefined
|
||||
if (status === 'passed' && gate.verify !== undefined) {
|
||||
try {
|
||||
await gate.verify({ gate, status, durationMs: performance.now() - started, stdout, stderr, exitCode })
|
||||
} catch (verifyError: unknown) {
|
||||
status = 'failed'
|
||||
error = verifyError instanceof Error ? verifyError.message : String(verifyError)
|
||||
}
|
||||
}
|
||||
|
||||
const result: GateResult = {
|
||||
gate,
|
||||
status,
|
||||
durationMs: performance.now() - started,
|
||||
stdout,
|
||||
stderr,
|
||||
exitCode,
|
||||
}
|
||||
if (error !== undefined) result.error = error
|
||||
return result
|
||||
}
|
||||
|
||||
function printResult(result: GateResult): void {
|
||||
const seconds = (result.durationMs / 1000).toFixed(2)
|
||||
console.log(`\n== ${result.status.toUpperCase()} ${result.gate.label} (${seconds}s) ==`)
|
||||
process.stdout.write(result.stdout)
|
||||
process.stderr.write(result.stderr)
|
||||
if (result.error !== undefined) console.error(result.error)
|
||||
}
|
||||
|
||||
function printSummary(results: GateResult[], durationMs: number): void {
|
||||
const passed = results.filter(result => result.status === 'passed').length
|
||||
const failed = results.filter(result => result.status === 'failed').length
|
||||
const skipped = results.filter(result => result.status === 'skipped').length
|
||||
const seconds = (durationMs / 1000).toFixed(2)
|
||||
console.log(`\nrun-gates: ${passed} passed, ${failed} failed, ${skipped} skipped in ${seconds}s.`)
|
||||
}
|
||||
@@ -9,9 +9,10 @@
|
||||
"excluded": [
|
||||
"docs/AGENTS.md",
|
||||
"docs/module-graph.md",
|
||||
"docs/config-catalog.md",
|
||||
"docs/tool-catalog.md",
|
||||
"docs/persistence-catalog.md",
|
||||
"docs/cordis-catalog/",
|
||||
"docs/tool-catalog/",
|
||||
"docs/persistence-catalog/",
|
||||
"docs/i18n/terminology.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -43,6 +43,18 @@
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionResult", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "PreToolDecision", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "PostToolDecision", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredScalar", "source": "packages/core/tools/src/json-schema.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredSchemaType", "source": "packages/core/tools/src/json-schema.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredSchemaNode", "source": "packages/core/tools/src/json-schema.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredOutputSchema", "source": "packages/core/tools/src/json-schema.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionOption", "source": "packages/ui/user-interaction/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionItem", "source": "packages/ui/user-interaction/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionRequest", "source": "packages/ui/user-interaction/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionAnswerItem", "source": "packages/ui/user-interaction/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionAnswer", "source": "packages/ui/user-interaction/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "UserInteractionProvider", "source": "packages/ui/user-interaction/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "UserInteractionError", "source": "packages/ui/user-interaction/src/index.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecRequest", "source": "packages/bash/bash/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecSpec", "source": "packages/bash/bash/src/types.ts" },
|
||||
@@ -51,6 +63,13 @@
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashTask", "source": "packages/bash/bash/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunRequest", "source": "packages/code-runtime/code-runtime/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunResult", "source": "packages/code-runtime/code-runtime/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeBindingNamespace", "source": "packages/code-runtime/code-runtime/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeBindingFunction", "source": "packages/code-runtime/code-runtime/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeLogEntry", "source": "packages/code-runtime/code-runtime/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunFailure", "source": "packages/code-runtime/code-runtime/src/types.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTarget", "source": "packages/fs/fs/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTargetKey", "source": "packages/fs/fs/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsVersion", "source": "packages/fs/fs/src/types.ts" },
|
||||
|
||||
@@ -26,9 +26,8 @@
|
||||
* Run: `tsx scripts/verify-doc-refs.ts`.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { existsSync, globSync, readFileSync } from 'node:fs'
|
||||
import { relative, resolve } from 'node:path'
|
||||
import { glob } from 'node:fs/promises'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
@@ -77,7 +76,7 @@ function findViolations(absPath: string): Violation[] {
|
||||
const all: Violation[] = []
|
||||
let checked = 0
|
||||
for (const pattern of PATTERNS) {
|
||||
for await (const match of glob(pattern, { cwd: root })) {
|
||||
for (const match of globSync(pattern, { cwd: root })) {
|
||||
if (isExcluded(match)) continue
|
||||
checked++
|
||||
all.push(...findViolations(resolve(root, match)))
|
||||
|
||||
643
scripts/verify-export-jsdoc.ts
Normal file
643
scripts/verify-export-jsdoc.ts
Normal file
@@ -0,0 +1,643 @@
|
||||
/**
|
||||
* 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, a const with a function
|
||||
* initializer or an INLINE callable annotation, or a non-identifier
|
||||
* function default export) 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>`. Wrapper expressions (parentheses, `as` / `satisfies`
|
||||
* casts, non-null assertions) are peeled before classifying. The walk
|
||||
* classifies returns syntactically, so the return type must be ANNOTATED —
|
||||
* except a const whose declarator is annotated with a NAMED type (e.g.
|
||||
* `export const f: Handler = …`), where that type's own declaration owns
|
||||
* the signature contract and `@returns` stays optional; an inline
|
||||
* `(x: T) => U` annotation or single-call-signature literal is the surface
|
||||
* signature itself and gets the full contract, and a literal mixing
|
||||
* call/construct signatures with anything else is refused (extract a named
|
||||
* type).
|
||||
* - 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 declared by 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 — UNLESS the override grows surface the
|
||||
* base never documented: a protected-only base member does not exempt a
|
||||
* public override, parameters the base never names keep their `@param`
|
||||
* duty, and a concrete result above a void base return keeps its
|
||||
* `@returns` duty. Heritage members (and classifying an unannotated
|
||||
* override's inferred return above a void base) are the questions the walk
|
||||
* asks the TYPE CHECKER; 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; in an ambient `declare` namespace every member exports
|
||||
* implicitly); 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. An `export import X = N.member` alias documents
|
||||
* ITSELF, and only prose-only target kinds are gate-supported: a callable,
|
||||
* class, or namespace target carries signature/member contracts the alias
|
||||
* cannot hold and is refused (export the declaration directly).
|
||||
* - Everything else fails CLOSED: `export =` is refused outright, and an
|
||||
* exported statement kind the dispatch does not recognize is itself a
|
||||
* violation, so no export form can pass unchecked by omission.
|
||||
*/
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
/**
|
||||
* Peel wrapper expressions that carry no surface of their own — parentheses,
|
||||
* `as` / `satisfies` / angle-bracket casts, non-null assertions — so a
|
||||
* wrapped function expression is still classified as function-like.
|
||||
* @param e - the expression to unwrap.
|
||||
* @returns the innermost non-wrapper expression.
|
||||
*/
|
||||
function unwrapExpression(e: ts.Expression): ts.Expression {
|
||||
let inner = e
|
||||
while (
|
||||
ts.isParenthesizedExpression(inner) || ts.isAsExpression(inner) || ts.isSatisfiesExpression(inner)
|
||||
|| ts.isNonNullExpression(inner) || ts.isTypeAssertionExpression(inner)
|
||||
) inner = inner.expression
|
||||
return inner
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a declarator's type annotation for the function contract: an
|
||||
* inline function type or a type literal that is EXACTLY one call signature
|
||||
* is the surface signature itself; a literal mixing call/construct
|
||||
* signatures with anything else cannot be classified syntactically and is
|
||||
* refused (fail closed — extract a named type); everything else is a plain
|
||||
* value shape.
|
||||
* @param type - the declarator's type annotation.
|
||||
* @returns the signature to check, 'refuse' for an unclassifiable callable literal, or null for a non-callable shape.
|
||||
*/
|
||||
function callableAnnotation(type: ts.TypeNode): ts.SignatureDeclarationBase | 'refuse' | null {
|
||||
if (ts.isFunctionTypeNode(type)) return type
|
||||
if (!ts.isTypeLiteralNode(type)) return null
|
||||
const signatures = type.members.filter(m => ts.isCallSignatureDeclaration(m) || ts.isConstructSignatureDeclaration(m))
|
||||
if (signatures.length === 0) return null
|
||||
if (signatures.length === 1 && type.members.length === 1 && signatures[0] !== undefined && ts.isCallSignatureDeclaration(signatures[0])) {
|
||||
return signatures[0]
|
||||
}
|
||||
return 'refuse'
|
||||
}
|
||||
|
||||
/**
|
||||
* The heritage-member exemption for one class member. When the member's name
|
||||
* is declared by an `extends`/`implements` heritage type, the seam declaration
|
||||
* is the doc's one home (the IDE inherits it on hover) and the member needs no
|
||||
* doc of its own — EXCEPT where the override grows public surface the base
|
||||
* never documented: a base member that is protected on every declaration does
|
||||
* not exempt a public override (consumers could not call it before);
|
||||
* parameters the base never names keep their own `@param` duty (the caller
|
||||
* reads the seam doc, which cannot describe them; an underscore-prefixed
|
||||
* rename of a base parameter — the deliberately-unused marker — is the same
|
||||
* parameter, not new surface); and a void base return carried no `@returns`
|
||||
* duty, so an override returning a concrete result documents it itself.
|
||||
* 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 null when no exemption applies; otherwise the parameter names the
|
||||
* base declarations carry (`baseParams: null` when not syntactically
|
||||
* recoverable — a complex heritage type — exempting all parameters) plus
|
||||
* whether every recoverable base return annotation is `void`-like
|
||||
* (`baseVoidReturn: null` when none is recoverable, exempting the result).
|
||||
*/
|
||||
function heritageExemption(
|
||||
cls: ts.ClassDeclaration,
|
||||
name: string,
|
||||
staticSide: boolean,
|
||||
checker: ts.TypeChecker,
|
||||
): { baseParams: Set<string> | null; baseVoidReturn: boolean | null } | null {
|
||||
const isProtected = (d: ts.Declaration): boolean =>
|
||||
(ts.canHaveModifiers(d) ? ts.getModifiers(d) : undefined)?.some(m => m.kind === ts.SyntaxKind.ProtectedKeyword) ?? false
|
||||
for (const clause of cls.heritageClauses ?? []) {
|
||||
for (const t of clause.types) {
|
||||
const type = staticSide ? checker.getTypeAtLocation(t.expression) : checker.getTypeAtLocation(t)
|
||||
const prop = type.getProperty(name)
|
||||
if (prop === undefined) continue
|
||||
const decls = prop.declarations ?? []
|
||||
if (decls.length > 0 && decls.every(isProtected)) continue // public override of a protected base: new surface
|
||||
let baseParams: Set<string> | null = null
|
||||
let baseVoidReturn: boolean | null = null
|
||||
for (const d of decls) {
|
||||
let params: readonly ts.ParameterDeclaration[] | undefined
|
||||
let returnType: ts.TypeNode | undefined
|
||||
if (ts.isMethodDeclaration(d) || ts.isMethodSignature(d)) {
|
||||
params = d.parameters
|
||||
returnType = d.type
|
||||
} else if ((ts.isPropertySignature(d) || ts.isPropertyDeclaration(d)) && d.type !== undefined && ts.isFunctionTypeNode(d.type)) {
|
||||
params = d.type.parameters
|
||||
returnType = d.type.type
|
||||
} else continue
|
||||
baseParams ??= new Set()
|
||||
// Leading underscores are the deliberately-unused marker (eslint
|
||||
// argsIgnorePattern), not a rename: `_cwd` overriding `cwd` is the
|
||||
// same parameter, so compare underscore-stripped on both sides.
|
||||
for (const p of params) if (ts.isIdentifier(p.name)) baseParams.add(p.name.text.replace(/^_+/, ''))
|
||||
if (returnType !== undefined) {
|
||||
const voidish = /^(void|Promise<void>)$/.test(returnType.getText(d.getSourceFile()).replace(/\s+/g, ' '))
|
||||
baseVoidReturn = (baseVoidReturn ?? true) && voidish
|
||||
}
|
||||
}
|
||||
return { baseParams, baseVoidReturn }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a method's INFERRED return type is void-like (void, undefined,
|
||||
* never, or a promise of one) — the one return the walk asks the checker to
|
||||
* classify: an unannotated override above a void heritage member, where
|
||||
* demanding an annotation just to prove faithfulness would be boilerplate.
|
||||
* @param m - a method declaration with no return type annotation.
|
||||
* @param checker - the program's type checker.
|
||||
* @returns true when the inferred result carries nothing to document.
|
||||
*/
|
||||
function inferredReturnIsVoidish(m: ts.MethodDeclaration, checker: ts.TypeChecker): boolean {
|
||||
const sig = checker.getSignatureFromDeclaration(m)
|
||||
if (sig === undefined) return true // no callable signature: nothing classifiable to document
|
||||
const returned = checker.getReturnTypeOfSignature(sig)
|
||||
const awaited = checker.getAwaitedType(returned) ?? returned
|
||||
return (awaited.flags & (ts.TypeFlags.Void | ts.TypeFlags.Undefined | ts.TypeFlags.Never)) !== 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 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). Heritage-declared members are exempt per heritageExemption (an
|
||||
* override's extra parameters keep their @param duty); 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
|
||||
const exemption = heritageExemption(cls, mname, isStatic(m), w.checker)
|
||||
if (ts.isMethodDeclaration(m)) {
|
||||
if (m.body && overloadSigs.has(mname)) continue // overload implementation: the signatures carry the docs
|
||||
const where = `exported class method '${name}.${mname}' (${pointer(w.rel, w.sf, m)})`
|
||||
if (exemption !== null) {
|
||||
const raw = rawJsDoc(w.text, m)
|
||||
// The heritage declaration owns the prose; parameters the base never
|
||||
// names — including binding patterns, which no base declaration can
|
||||
// name — are new surface and keep their @param duty.
|
||||
const base = exemption.baseParams
|
||||
const inBase = (p: ts.ParameterDeclaration): boolean =>
|
||||
base !== null && ts.isIdentifier(p.name) && base.has(p.name.text.replace(/^_+/, ''))
|
||||
if (base !== null && m.parameters.some(p => !thisReceiver(p) && !inBase(p))) {
|
||||
checkParams(where, 'export', m.parameters, parseTags(raw).params, w.sf,
|
||||
p => thisReceiver(p) || inBase(p), w.violations)
|
||||
}
|
||||
// A void base return carried no @returns duty, so an override growing
|
||||
// a concrete result documents it itself. An annotated override runs
|
||||
// the standard check; an inferred one is classified by the checker
|
||||
// (this branch is already the checker's domain), so a faithful void
|
||||
// override stays exempt without a boilerplate annotation.
|
||||
if (exemption.baseVoidReturn === true) {
|
||||
if (m.type !== undefined) {
|
||||
checkReturns(where, m.type, parseTags(raw).returns, w.sf, w.violations)
|
||||
} else if (!inferredReturnIsVoidish(m, w.checker)) {
|
||||
w.violations.push(`${where} returns a non-void result its heritage declaration does not document; annotate the return type and add @returns.`)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
checkFunctionLike(where, rawJsDoc(w.text, m), m.parameters, m.type, false, w)
|
||||
} else if (exemption !== null) {
|
||||
continue // the heritage declaration owns the doc (properties/accessors carry no own parameters)
|
||||
} 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. Any
|
||||
* exported statement kind the dispatch does not recognize is a violation
|
||||
* (fail closed), so no export form can pass unchecked by omission.
|
||||
* @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 ambient - whether the enclosing scope is ambient (`declare`), where members export implicitly.
|
||||
* @param w - the walk state violations append to.
|
||||
* @param only - for a multi-declarator variable statement reached through an
|
||||
* export list (or a default-export identifier), the declarator names that
|
||||
* are actually exported; `null` means the whole statement is surface
|
||||
* (direct `export` modifier or ambient scope). Non-variable statements
|
||||
* declare exactly one name, so the filter never applies to them.
|
||||
*/
|
||||
function checkDecl(
|
||||
stmt: ts.Statement,
|
||||
prefix: string,
|
||||
overloadSigs: Set<string>,
|
||||
byName: Map<string, ts.Statement[]>,
|
||||
ambient: boolean,
|
||||
w: Walk,
|
||||
only: ReadonlySet<string> | null = null,
|
||||
): 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 (only !== null && !only.has(name)) continue // sibling declarator the export list never named: not surface
|
||||
if (prefix === '' && PROTOCOL_EXPORTS.has(name)) continue // cordis plugin-protocol slot
|
||||
const where = `exported const '${prefix}${name}'${at(d)}`
|
||||
const annotation = d.type !== undefined ? callableAnnotation(d.type) : null
|
||||
const init = d.initializer !== undefined ? unwrapExpression(d.initializer) : undefined
|
||||
if (annotation === 'refuse') {
|
||||
// A literal mixing call/construct signatures with other members (or
|
||||
// overloading them) has no single signature the walk can hold the
|
||||
// tags against — fail closed rather than silently narrow the check.
|
||||
w.violations.push(`${where}: its callable type literal is not gate-classifiable; extract a named type and document it there.`)
|
||||
} else if (annotation !== null) {
|
||||
// An INLINE callable annotation is the surface signature itself: its
|
||||
// parameters and result need docs right here. (A NAMED reference
|
||||
// type carries its docs at the type's own declaration instead.)
|
||||
checkFunctionLike(where, raw, annotation.parameters, annotation.type, false, w)
|
||||
} else if (init !== undefined && (ts.isArrowFunction(init) || ts.isFunctionExpression(init))) {
|
||||
// A named 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
|
||||
}
|
||||
// In an ambient (`declare`) namespace body, members are implicitly
|
||||
// exported — no `export` modifier required — so the recursion must treat
|
||||
// every statement as surface.
|
||||
const declared = ambient
|
||||
|| ((ts.canHaveModifiers(stmt) ? ts.getModifiers(stmt) : undefined)?.some(m => m.kind === ts.SyntaxKind.DeclareKeyword) ?? false)
|
||||
if (body !== undefined && ts.isModuleBlock(body)) checkScope(body.statements, nsPrefix, w, declared)
|
||||
return
|
||||
}
|
||||
if (ts.isImportEqualsDeclaration(stmt)) {
|
||||
const where = `exported alias '${prefix}${stmt.name.text}'${at(stmt)}`
|
||||
// An alias is a distinct exported name whose target may be a non-exported
|
||||
// namespace member no walk ever visits, so it documents ITSELF — which
|
||||
// matches the gate's strength only for prose-only target kinds. A
|
||||
// callable, class, or namespace target carries signature or member
|
||||
// contracts the alias prose cannot hold: refuse those (fail closed) and
|
||||
// demand the declaration be exported directly. An unresolvable target is
|
||||
// refused for the same reason.
|
||||
const sym = w.checker.getSymbolAtLocation(stmt.name)
|
||||
const target = sym !== undefined && (sym.flags & ts.SymbolFlags.Alias) !== 0 ? w.checker.getAliasedSymbol(sym) : sym
|
||||
const RICH_TARGETS = ts.SymbolFlags.Function | ts.SymbolFlags.Class | ts.SymbolFlags.ValueModule | ts.SymbolFlags.NamespaceModule
|
||||
const rich = target === undefined
|
||||
|| (target.flags & RICH_TARGETS) !== 0
|
||||
|| w.checker.getTypeOfSymbol(target).getCallSignatures().length > 0
|
||||
if (rich) {
|
||||
w.violations.push(`${where} aliases a callable, class, or namespace target whose signature/member contract the alias cannot carry; export the declaration directly instead.`)
|
||||
return
|
||||
}
|
||||
checkDescribed(where, rawJsDoc(w.text, stmt), w)
|
||||
return
|
||||
}
|
||||
// Fail CLOSED: an exported statement kind this dispatch does not recognize
|
||||
// must never pass silently — the gate's whole promise is that unchecked
|
||||
// surface cannot exist. New TypeScript export forms extend the gate here.
|
||||
w.violations.push(`exported statement${at(stmt)} uses an export form verify-export-jsdoc does not handle; extend the gate.`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param ambient - whether this scope is ambient (`declare` namespace or a declaration file), where members export implicitly.
|
||||
*/
|
||||
function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk, ambient: boolean): 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Two-phase dispatch. Phase one accumulates WHICH statements are surface
|
||||
// and, for a variable statement reached by name (an export list or a
|
||||
// default-export identifier), which of its declarators the exports actually
|
||||
// name — `null` marks the whole statement as surface (a direct `export`
|
||||
// modifier, or an ambient scope). Requests for the same statement merge:
|
||||
// `null` absorbs any name set, and name sets union, so
|
||||
// `export { a }; export { b }` over one `const a = …, b = …` checks both
|
||||
// declarators while a never-exported sibling stays out of the surface.
|
||||
// Phase two runs each surfaced statement exactly once. (Checking a
|
||||
// statement eagerly per request would either re-check on the second list or
|
||||
// — deduplicated — silently drop the second list's declarators.)
|
||||
const requested = new Map<ts.Statement, Set<string> | null>()
|
||||
const request = (stmt: ts.Statement, name: string | null): void => {
|
||||
const prior = requested.get(stmt)
|
||||
if (name === null || prior === null) {
|
||||
requested.set(stmt, null)
|
||||
return
|
||||
}
|
||||
requested.set(stmt, prior === undefined ? new Set([name]) : prior.add(name))
|
||||
}
|
||||
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) {
|
||||
const local = (el.propertyName ?? el.name).text
|
||||
for (const decl of byName.get(local) ?? []) request(decl, local)
|
||||
// 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)) {
|
||||
if (stmt.isExportEquals) {
|
||||
// `export =` has no ESM consumer surface in this repo and the walk
|
||||
// cannot classify its operand's shape; refuse rather than fail open.
|
||||
w.violations.push(`export-equals assignment (${pointer(w.rel, w.sf, stmt)}) is not a gate-supported export form; use ESM named exports.`)
|
||||
continue
|
||||
}
|
||||
const where = `default export (${pointer(w.rel, w.sf, stmt)})`
|
||||
const expr = unwrapExpression(stmt.expression)
|
||||
if (ts.isIdentifier(expr)) {
|
||||
for (const decl of byName.get(expr.text) ?? []) request(decl, expr.text)
|
||||
} else if (ts.isArrowFunction(expr) || ts.isFunctionExpression(expr)) {
|
||||
checkFunctionLike(where, rawJsDoc(w.text, stmt), expr.parameters, expr.type, false, w)
|
||||
} else {
|
||||
checkDescribed(where, rawJsDoc(w.text, stmt), w)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (isExported(stmt) || (ambient && !ts.isImportDeclaration(stmt))) request(stmt, null)
|
||||
}
|
||||
for (const stmt of statements) {
|
||||
const only = requested.get(stmt)
|
||||
if (only !== undefined) checkDecl(stmt, prefix, overloadSigs, byName, ambient, w, only)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 `noLib` + no `@types` — fixtures are single-file and
|
||||
* self-contained, nothing in the walk resolves a lib symbol, and default-lib
|
||||
* parsing is ~99% of per-program cost (it made the fixture spec time out
|
||||
* under CI coverage instrumentation). 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, noLib: true, types: [] }
|
||||
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
|
||||
// A script-style declaration file (no imports/exports) is one big ambient
|
||||
// scope; a module-style .d.ts still honors explicit export modifiers.
|
||||
checkScope(sf.statements, '', { rel, sf, text: sf.text, checker, violations }, sf.isDeclarationFile && !ts.isExternalModule(sf))
|
||||
}
|
||||
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()
|
||||
}
|
||||
@@ -32,9 +32,8 @@
|
||||
* Run: `tsx scripts/verify-md-links.ts`.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { existsSync, globSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { dirname, relative, resolve } from 'node:path'
|
||||
import { glob } from 'node:fs/promises'
|
||||
import { fromMarkdown } from 'mdast-util-from-markdown'
|
||||
import { gfmFromMarkdown } from 'mdast-util-gfm'
|
||||
import { gfm } from 'micromark-extension-gfm'
|
||||
@@ -134,7 +133,7 @@ const seen = new Set<string>()
|
||||
const all: Violation[] = []
|
||||
let checked = 0
|
||||
for (const pattern of PATTERNS) {
|
||||
for await (const match of glob(pattern, { cwd: root })) {
|
||||
for (const match of globSync(pattern, { cwd: root })) {
|
||||
const abs = resolve(root, match)
|
||||
// CLAUDE.md symlinks resolve onto AGENTS.md; dedupe by real path so a file
|
||||
// matched twice (or via symlink) is checked once.
|
||||
|
||||
@@ -25,9 +25,8 @@
|
||||
* Run: `tsx scripts/verify-md-wrap.ts`.
|
||||
*/
|
||||
|
||||
import { readFileSync, realpathSync } from 'node:fs'
|
||||
import { globSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { relative, resolve } from 'node:path'
|
||||
import { glob } from 'node:fs/promises'
|
||||
import { fromMarkdown } from 'mdast-util-from-markdown'
|
||||
import { gfmFromMarkdown } from 'mdast-util-gfm'
|
||||
import { gfm } from 'micromark-extension-gfm'
|
||||
@@ -76,7 +75,7 @@ const seen = new Set<string>()
|
||||
const all: Violation[] = []
|
||||
let checked = 0
|
||||
for (const pattern of PATTERNS) {
|
||||
for await (const match of glob(pattern, { cwd: root })) {
|
||||
for (const match of globSync(pattern, { cwd: root })) {
|
||||
const abs = resolve(root, match)
|
||||
// CLAUDE.md symlinks resolve onto AGENTS.md; dedupe by real path so a file
|
||||
// matched twice (or via symlink) is checked once.
|
||||
|
||||
@@ -12,9 +12,8 @@
|
||||
* Run: `tsx scripts/verify-mermaid.ts`.
|
||||
*/
|
||||
|
||||
import { readFileSync, realpathSync } from 'node:fs'
|
||||
import { globSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { glob } from 'node:fs/promises'
|
||||
import { fromMarkdown } from 'mdast-util-from-markdown'
|
||||
import { gfmFromMarkdown } from 'mdast-util-gfm'
|
||||
import { gfm } from 'micromark-extension-gfm'
|
||||
@@ -72,7 +71,7 @@ const blocks: Block[] = []
|
||||
const seen = new Set<string>()
|
||||
let checkedFiles = 0
|
||||
for (const pattern of PATTERNS) {
|
||||
for await (const match of glob(pattern, { cwd: root })) {
|
||||
for (const match of globSync(pattern, { cwd: root })) {
|
||||
const real = realpathSync(resolve(root, match))
|
||||
if (seen.has(real)) continue
|
||||
seen.add(real)
|
||||
|
||||
@@ -39,9 +39,8 @@
|
||||
* Run: `tsx scripts/verify-package-paths.ts`.
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { existsSync, globSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { relative, resolve } from 'node:path'
|
||||
import { glob } from 'node:fs/promises'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
@@ -144,7 +143,7 @@ const all: Violation[] = []
|
||||
let checked = 0
|
||||
const seen = new Set<string>()
|
||||
for (const pattern of PATTERNS) {
|
||||
for await (const match of glob(pattern, { cwd: root })) {
|
||||
for (const match of globSync(pattern, { cwd: root })) {
|
||||
if (isExcluded(match)) continue
|
||||
// Dedup by real path: the root/packages CLAUDE.md are symlinks to AGENTS.md.
|
||||
const real = realpathSync(resolve(root, match))
|
||||
|
||||
@@ -44,9 +44,8 @@
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { basename, join, resolve } from 'node:path'
|
||||
import { glob } from 'node:fs/promises'
|
||||
import { fromMarkdown } from 'mdast-util-from-markdown'
|
||||
import { gfmFromMarkdown } from 'mdast-util-gfm'
|
||||
import { gfm } from 'micromark-extension-gfm'
|
||||
@@ -211,7 +210,7 @@ function parse(content: string): Nodes {
|
||||
// Enumerate the scope once.
|
||||
const files = new Set<string>()
|
||||
for (const pattern of SCOPE_PATTERNS) {
|
||||
for await (const match of glob(pattern, { cwd: root })) files.add(match)
|
||||
for (const match of globSync(pattern, { cwd: root })) files.add(match)
|
||||
}
|
||||
const translations = [...files].filter(f => f.endsWith('.zh.md')).sort()
|
||||
const metas = [...files].filter(f => f.endsWith('.i18n.yaml')).sort()
|
||||
|
||||
@@ -22,9 +22,8 @@
|
||||
* Run: `tsx scripts/verify-type-equiv.ts`.
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { globSync, readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { glob } from 'node:fs/promises'
|
||||
import ts from 'typescript'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
@@ -148,7 +147,7 @@ const keyOf = (x: { doc: string; symbol: string }): string => `${x.doc}::${x.sym
|
||||
// as an orphan rather than silently skipped.
|
||||
const docSet = new Set<string>()
|
||||
for (const pattern of MARKDOWN_GLOBS) {
|
||||
for await (const match of glob(pattern, { cwd: root })) docSet.add(match)
|
||||
for (const match of globSync(pattern, { cwd: root })) docSet.add(match)
|
||||
}
|
||||
const blocks: EquivBlock[] = [...docSet].sort().flatMap(extractEquivBlocks)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user