Merge remote-tracking branch 'origin/master' into codex/ask-user-question
# Conflicts: # examples/acp-agent/tests/snapshots/cancel/session.jsonl # examples/acp-agent/tests/snapshots/error-finish/session.jsonl # examples/acp-agent/tests/snapshots/fs-edit/session.jsonl # examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl # examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl # examples/acp-agent/tests/snapshots/fs-read/session.jsonl # examples/acp-agent/tests/snapshots/fs-terminal-card/session.jsonl # examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl # examples/acp-agent/tests/snapshots/fs-write/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl # examples/acp-agent/tests/snapshots/multi-turn/session.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl # examples/acp-agent/tests/snapshots/todo-plan/session.jsonl # examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl # examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"AGENTS.md": 1575,
|
||||
"AGENTS.md": 1660,
|
||||
"docs/AGENTS.md": 1315,
|
||||
"docs/architecture.md": 1630,
|
||||
"docs/cordis-primer.md": 550,
|
||||
|
||||
@@ -9,15 +9,17 @@
|
||||
* 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`.
|
||||
*/
|
||||
@@ -49,8 +51,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 +68,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 +96,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
|
||||
|
||||
928
scripts/gen-config-catalog.ts
Normal file
928
scripts/gen-config-catalog.ts
Normal file
@@ -0,0 +1,928 @@
|
||||
/**
|
||||
* 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, parseJsDoc, pointer, rawJsDoc } from './gen-cordis-catalog.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()
|
||||
}
|
||||
@@ -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',
|
||||
@@ -146,14 +148,16 @@ 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 {
|
||||
/** Repo-relative source pointer `file:line` for a node's first character.
|
||||
* Shared with `gen-config-catalog.ts`. */
|
||||
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. */
|
||||
function rawJsDoc(text: string, node: ts.Node): string {
|
||||
/** The raw `/** … */` JSDoc block immediately preceding a node, or '' if none.
|
||||
* Shared with `gen-config-catalog.ts`. */
|
||||
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) : ''
|
||||
@@ -167,9 +171,10 @@ function rawJsDoc(text: string, node: ts.Node): string {
|
||||
* (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.
|
||||
* invisible to the rendered catalog. Shared with `gen-config-catalog.ts`
|
||||
* (which uses only the prose-presence half).
|
||||
*/
|
||||
function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
|
||||
export function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
|
||||
const inner = raw
|
||||
.replace(/^\/\*\*/, '')
|
||||
.replace(/\*\/$/, '')
|
||||
|
||||
@@ -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
|
||||
@@ -733,7 +733,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'}\` |`
|
||||
@@ -742,7 +742,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).',
|
||||
'',
|
||||
|
||||
@@ -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
|
||||
@@ -55,7 +55,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
|
||||
@@ -270,7 +270,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
|
||||
}
|
||||
|
||||
@@ -290,9 +290,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.',
|
||||
'',
|
||||
|
||||
@@ -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)
|
||||
|
||||
431
scripts/run-gates.ts
Normal file
431
scripts/run-gates.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* 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('tool-catalog', 'verify-tool-catalog', { label: 'tool 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',
|
||||
], {
|
||||
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"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user