mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge refreshed rfc/pty into feature/persistent-pty-sessions
# Conflicts: # docs/architecture.md # docs/capability-seams.md # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/module-graph.md # docs/tool-catalog.md # examples/acp-agent/tests/acp.snapshot.ts # examples/headless-agent/tests/headless.snapshot.ts # examples/package.json # packages/README.md # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/tools/tests/gen-tool-catalog.spec.ts # pnpm-lock.yaml # pnpm-workspace.yaml # scripts/gen-tool-catalog.ts # scripts/type-equiv.manifest.json # website/.vitepress/config/api-sidebar.json
This commit is contained in:
3
scripts/AGENTS.md
Normal file
3
scripts/AGENTS.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# AGENTS.md — Repository scripts
|
||||
|
||||
Gate scripts invoke pnpm shell-free, normalize repository-relative glob paths to `/` at ingestion, and keep platform adaptation at the owning gate boundary instead of a shared platform layer.
|
||||
@@ -40,10 +40,12 @@ interface PackageManifest {
|
||||
bin?: string | Record<string, string>
|
||||
exports?: Record<
|
||||
string,
|
||||
| string
|
||||
| {
|
||||
types?: string
|
||||
default?: string
|
||||
}
|
||||
| null
|
||||
| undefined
|
||||
>
|
||||
files?: string[]
|
||||
@@ -93,29 +95,6 @@ function workspaceManifests(): WorkspaceManifest[] {
|
||||
return manifests
|
||||
}
|
||||
|
||||
const dshPackageFiles = [
|
||||
'lib/index.js',
|
||||
'lib/types/**/*.d.ts',
|
||||
'lib/types/**/*.d.ts.map',
|
||||
'src',
|
||||
] as const
|
||||
|
||||
const dshBinPackageFiles = [
|
||||
'lib/index.js',
|
||||
'lib/bin.js',
|
||||
'lib/types/**/*.d.ts',
|
||||
'lib/types/**/*.d.ts.map',
|
||||
'src',
|
||||
] as const
|
||||
|
||||
const dshWorkerPackageFiles = [
|
||||
'lib/index.js',
|
||||
'lib/worker.cjs',
|
||||
'lib/types/**/*.d.ts',
|
||||
'lib/types/**/*.d.ts.map',
|
||||
'src',
|
||||
] as const
|
||||
|
||||
const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
|
||||
'@deepseek-ai/dsh-helper': ['lib/assets'],
|
||||
'@deepseek-ai/dsh-scripts': [
|
||||
@@ -131,22 +110,46 @@ function sameStringList(actual: readonly string[] | undefined, expected: readonl
|
||||
|
||||
function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] {
|
||||
const extras = manifest.name ? packageFileExtras[manifest.name] ?? [] : []
|
||||
if (extras.length > 0) {
|
||||
return [
|
||||
'lib/index.js',
|
||||
...manifest.bin ? ['lib/bin.js'] : [],
|
||||
...extras,
|
||||
'lib/types/**/*.d.ts',
|
||||
'lib/types/**/*.d.ts.map',
|
||||
'src',
|
||||
]
|
||||
}
|
||||
if (manifest.bin) return dshBinPackageFiles
|
||||
// A declared "./worker" subpath export sanctions the one extra runtime
|
||||
// bundle a worker-thread entry needs (and NodeNext/publint then validate
|
||||
// that subpath's targets like any other export).
|
||||
if (manifest.exports?.['./worker']) return dshWorkerPackageFiles
|
||||
return dshPackageFiles
|
||||
return [
|
||||
'lib/index.js',
|
||||
// Every package publishes its invariant ownership companion as a separate
|
||||
// bundle; the package-invariant gate validates the companion itself.
|
||||
'lib/invariant.js',
|
||||
...manifest.bin ? ['lib/bin.js'] : [],
|
||||
...manifest.exports?.['./worker'] ? ['lib/worker.cjs'] : [],
|
||||
// UI plugin packages ship their browser bundle beside the node lib
|
||||
// (single-artifact ruling: dist/ retired, ./client resolves lib/client.js).
|
||||
// Keyed on the artifact path, not the subpath name: apiproxy's ./client is
|
||||
// a browser-safe source channel, not a bundle.
|
||||
...exportDefault(manifest, './client') === './lib/client.js' ? ['lib/client.js'] : [],
|
||||
// runtime's shell-held loader subpath ships as its own bundle beside the client half.
|
||||
...exportDefault(manifest, './loader') === './lib/loader.js' ? ['lib/loader.js'] : [],
|
||||
// web-react's store subpath ships its own bundle (single-entry builds; no shared chunk).
|
||||
...exportDefault(manifest, './store') === './lib/store/index.js' ? ['lib/store/index.js'] : [],
|
||||
...extras,
|
||||
// Subpaths whose runtime default is the tsc-emitted tree (lib/types/*.js —
|
||||
// browser-safe source channels rehomed off src so plain Node can import
|
||||
// them without type stripping) publish the emitted JS alongside the
|
||||
// declarations.
|
||||
...usesEmittedTreeDefaults(manifest) ? ['lib/types/**/*.js'] : [],
|
||||
'lib/types/**/*.d.ts',
|
||||
'lib/types/**/*.d.ts.map',
|
||||
'src',
|
||||
]
|
||||
}
|
||||
|
||||
/** Runtime target of an export entry: conditional `default`, or the bare-string shorthand. */
|
||||
function exportDefault(manifest: PackageManifest, subpath: string): string | undefined {
|
||||
const entry = manifest.exports?.[subpath]
|
||||
if (typeof entry === 'string') return entry
|
||||
if (typeof entry === 'object' && entry !== null) return entry.default
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Whether any export's runtime default points into the tsc-emitted lib/types tree. */
|
||||
function usesEmittedTreeDefaults(manifest: PackageManifest): boolean {
|
||||
return Object.keys(manifest.exports ?? {}).some(subpath =>
|
||||
exportDefault(manifest, subpath)?.startsWith('./lib/types/') === true)
|
||||
}
|
||||
|
||||
function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
|
||||
@@ -182,12 +185,25 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
|
||||
if (manifest.types !== 'lib/types/index.d.ts') {
|
||||
errors.push(`${label}: package.json must set "types": "lib/types/index.d.ts"`)
|
||||
}
|
||||
if (manifest.exports?.['.']?.types !== './lib/types/index.d.ts') {
|
||||
const rootExport = manifest.exports?.['.']
|
||||
const rootEntry = typeof rootExport === 'object' && rootExport !== null ? rootExport : undefined
|
||||
if (rootEntry?.types !== './lib/types/index.d.ts') {
|
||||
errors.push(`${label}: package.json exports["."].types must be "./lib/types/index.d.ts"`)
|
||||
}
|
||||
if (manifest.exports?.['.']?.default !== './lib/index.js') {
|
||||
if (rootEntry?.default !== './lib/index.js') {
|
||||
errors.push(`${label}: package.json exports["."].default must be "./lib/index.js"`)
|
||||
}
|
||||
const invariantRaw = manifest.exports?.['./invariant']
|
||||
const invariantExport = typeof invariantRaw === 'object' && invariantRaw !== null ? invariantRaw : undefined
|
||||
if (invariantExport?.types !== undefined && invariantExport.types !== './lib/types/invariant.d.ts') {
|
||||
errors.push(`${label}: package.json exports["./invariant"].types must be "./lib/types/invariant.d.ts"`)
|
||||
}
|
||||
if (invariantExport?.default !== undefined && invariantExport.default !== './lib/invariant.js') {
|
||||
errors.push(`${label}: package.json exports["./invariant"].default must be "./lib/invariant.js"`)
|
||||
}
|
||||
if (invariantExport && (invariantExport.types === undefined || invariantExport.default === undefined)) {
|
||||
errors.push(`${label}: package.json exports["./invariant"] must declare both types and default targets`)
|
||||
}
|
||||
const expectedFiles = expectedDshPackageFiles(manifest)
|
||||
if (!sameStringList(manifest.files, expectedFiles)) {
|
||||
errors.push(`${label}: package.json files must be ${JSON.stringify(expectedFiles)}`)
|
||||
|
||||
60
scripts/client-bundle-purity.spec.ts
Normal file
60
scripts/client-bundle-purity.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Pins the client-bundle purity gate (tsdown preset resolveId classifier):
|
||||
* a bare-name import of a module-table package must rewrite to its /client
|
||||
* external form (inlining it duplicates runtime identity — the P0
|
||||
/* leak that is not an
|
||||
* inline-safe wire layer must fail the build loudly.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CLIENT_EXTERNALS, clientBundle } from '../packages/client/tsdown.client.ts'
|
||||
|
||||
type ResolveId = (source: string) => null | { id: string; external: boolean }
|
||||
|
||||
function purityResolveId(): ResolveId {
|
||||
// libEntry is spelled at every call site (no default) so the
|
||||
// package-invariants text check can see the invariant entry per package.
|
||||
const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
const plugins = (configs[1] as { plugins: { name: string; resolveId?: unknown }[] }).plugins
|
||||
const gate = plugins.find(p => p.name === 'dsh-client-bundle-purity')
|
||||
if (gate?.resolveId === undefined) throw new Error('purity plugin missing from client config')
|
||||
return gate.resolveId as ResolveId
|
||||
}
|
||||
|
||||
describe('client bundle purity gate', () => {
|
||||
const resolveId = purityResolveId()
|
||||
|
||||
it('leaves table entries and non-scoped specifiers alone', () => {
|
||||
expect(resolveId('@deepseek-ai/dsh-client-ui-slots')).toBeNull()
|
||||
expect(resolveId('@deepseek-ai/dsh-client-runtime/client')).toBeNull()
|
||||
expect(resolveId('react')).toBeNull()
|
||||
expect(resolveId('zod')).toBeNull()
|
||||
})
|
||||
|
||||
it('rewrites a bare table-package name to its external /client form (duplicate-instance prevention)', () => {
|
||||
expect(resolveId('@deepseek-ai/dsh-client-connection')).toEqual({
|
||||
id: '@deepseek-ai/dsh-client-connection/client',
|
||||
external: true,
|
||||
})
|
||||
expect(resolveId('@deepseek-ai/dsh-client-ui-layout')).toEqual({
|
||||
id: '@deepseek-ai/dsh-client-ui-layout/client',
|
||||
external: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('lets inline-safe wire layers inline', () => {
|
||||
expect(resolveId('@deepseek-ai/dsh-host-apiproxy/api')).toBeNull()
|
||||
expect(resolveId('@deepseek-ai/dsh-session/surface')).toBeNull()
|
||||
expect(resolveId('@deepseek-ai/dsh-brand')).toBeNull()
|
||||
})
|
||||
|
||||
it('throws on any other @deepseek-ai leak', () => {
|
||||
expect(() => resolveId('@deepseek-ai/dsh-agent')).toThrow(/purity/)
|
||||
expect(() => resolveId('@deepseek-ai/dsh-client-web')).toThrow(/purity/)
|
||||
})
|
||||
|
||||
it('every /client external has no bare-name twin in the table (the rewrite assumption)', () => {
|
||||
for (const entry of CLIENT_EXTERNALS) {
|
||||
if (entry.endsWith('/client')) expect(CLIENT_EXTERNALS).not.toContain(entry.slice(0, -'/client'.length))
|
||||
}
|
||||
})
|
||||
})
|
||||
49
scripts/cordis-core-api.spec.ts
Normal file
49
scripts/cordis-core-api.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/** Tests for the generated Cordis core API reference. */
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
CORDIS_CORE_API_PAGES,
|
||||
renderCordisCoreApiPage,
|
||||
renderCordisCoreApiPages,
|
||||
type CordisCoreApiPage,
|
||||
} from './cordis-core-api.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('Cordis core API generation', () => {
|
||||
it('renders the five detailed pages from pinned vendor declarations', () => {
|
||||
const pages = renderCordisCoreApiPages()
|
||||
expect([...pages.keys()]).toEqual(CORDIS_CORE_API_PAGES.map(page => page.out))
|
||||
expect(pages.get('docs/cordis-catalog/core/context.md')).toContain('### ctx.extend(meta?)')
|
||||
expect(pages.get('docs/cordis-catalog/core/events.md')).toContain('## DispatchMode')
|
||||
expect(pages.get('docs/cordis-catalog/core/fiber.md')).toContain('## EffectMeta')
|
||||
expect(pages.get('docs/cordis-catalog/core/registry.md')).toContain('## Plugin')
|
||||
expect(pages.get('docs/cordis-catalog/core/service.md')).toContain('### Service.resolveConfig')
|
||||
|
||||
const fiber = pages.get('docs/cordis-catalog/core/fiber.md') ?? ''
|
||||
expect(fiber).toContain('```\n\nRegister a cleanup-aware effect on this fiber.')
|
||||
expect(fiber).toContain('- `execute` — the effect body; see `Effect` for accepted shapes.')
|
||||
expect(fiber).toContain('**Returns** a disposer that tears the effect down and settles once done.')
|
||||
})
|
||||
|
||||
it('rejects a public core class without source JSDoc', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-cordis-core-api-'))
|
||||
roots.push(root)
|
||||
mkdirSync(join(root, 'vendor/cordis/src'), { recursive: true })
|
||||
writeFileSync(join(root, 'vendor/cordis/src/service.ts'), 'export class Service {\n run(): string { return "ok" }\n}\n')
|
||||
const page: CordisCoreApiPage = {
|
||||
out: 'docs/cordis-catalog/core/service.md',
|
||||
title: 'Service',
|
||||
intro: 'Service API.',
|
||||
sections: [{ kind: 'class', file: 'vendor/cordis/src/service.ts', symbol: 'Service' }],
|
||||
}
|
||||
expect(() => renderCordisCoreApiPage(page, root)).toThrow('class Service')
|
||||
})
|
||||
})
|
||||
433
scripts/cordis-core-api.ts
Normal file
433
scripts/cordis-core-api.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
/** Generate detailed Cordis core API pages from pinned vendor declarations. */
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations } from './jsdoc.ts'
|
||||
import { cordisModuleBody } from './cordis-walk.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const FENCE = 'ts cordis-catalog'
|
||||
|
||||
/** One declaration group rendered on a Cordis core API page. */
|
||||
type CordisCoreApiSection =
|
||||
| { kind: 'class'; file: string; symbol: string; prefix?: string; heading?: string }
|
||||
| { kind: 'context-merge'; file: string; heading?: string }
|
||||
| { kind: 'decl'; file: string; symbol: string }
|
||||
|
||||
/** One generated Cordis core API page. */
|
||||
export interface CordisCoreApiPage {
|
||||
out: string
|
||||
title: string
|
||||
intro: string
|
||||
sections: CordisCoreApiSection[]
|
||||
}
|
||||
|
||||
/** Explicit editorial grouping for the pinned Cordis core surface. */
|
||||
export const CORDIS_CORE_API_PAGES: CordisCoreApiPage[] = [
|
||||
{
|
||||
out: 'docs/cordis-catalog/core/context.md',
|
||||
title: 'Context',
|
||||
intro: 'The context is the core Cordis object: every service, event, and lifecycle API is reached through `ctx`. Event methods are documented on [Events](events.md), effects and the current fiber on [Fiber](fiber.md), and plugin loading on [Registry](registry.md).',
|
||||
sections: [
|
||||
{ kind: 'class', file: 'vendor/cordis/src/context.ts', symbol: 'Context', prefix: 'ctx.' },
|
||||
{ kind: 'context-merge', file: 'vendor/cordis/src/reflect.ts', heading: 'Service store and mixins' },
|
||||
],
|
||||
},
|
||||
{
|
||||
out: 'docs/cordis-catalog/core/events.md',
|
||||
title: 'Events',
|
||||
intro: 'The event-dispatch API mixed into every context. Harness event declarations and their dispatch modes are generated separately in the [Cordis events catalog](../events.md).',
|
||||
sections: [
|
||||
{ kind: 'context-merge', file: 'vendor/cordis/src/events.ts' },
|
||||
{ kind: 'decl', file: 'vendor/cordis/src/events.ts', symbol: 'EventOptions' },
|
||||
{ kind: 'decl', file: 'vendor/cordis/src/events.ts', symbol: 'DispatchMode' },
|
||||
],
|
||||
},
|
||||
{
|
||||
out: 'docs/cordis-catalog/core/fiber.md',
|
||||
title: 'Fiber',
|
||||
intro: 'A fiber is one loaded plugin instance: its lifecycle state, validated config, and registered effects. `ctx.fiber` is the current fiber, and `ctx.effect()` delegates to it.',
|
||||
sections: [
|
||||
{ kind: 'context-merge', file: 'vendor/cordis/src/fiber.ts' },
|
||||
{ kind: 'class', file: 'vendor/cordis/src/fiber.ts', symbol: 'Fiber', heading: 'The Fiber class' },
|
||||
{ kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'Effect' },
|
||||
{ kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'Disposable' },
|
||||
{ kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'EffectMeta' },
|
||||
{ kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'CordisError' },
|
||||
{ kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'ValidationError' },
|
||||
],
|
||||
},
|
||||
{
|
||||
out: 'docs/cordis-catalog/core/registry.md',
|
||||
title: 'Registry',
|
||||
intro: 'Plugin loading and dependency injection.',
|
||||
sections: [
|
||||
{ kind: 'context-merge', file: 'vendor/cordis/src/registry.ts' },
|
||||
{ kind: 'decl', file: 'vendor/cordis/src/registry.ts', symbol: 'Plugin' },
|
||||
{ kind: 'decl', file: 'vendor/cordis/src/registry.ts', symbol: 'Inject' },
|
||||
],
|
||||
},
|
||||
{
|
||||
out: 'docs/cordis-catalog/core/service.md',
|
||||
title: 'Service',
|
||||
intro: 'The base class for context services. A subclass loaded as a plugin registers itself as `ctx.<name>`.',
|
||||
sections: [
|
||||
{ kind: 'class', file: 'vendor/cordis/src/service.ts', symbol: 'Service' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
interface MemberDoc {
|
||||
name: string
|
||||
heading: string
|
||||
signatures: string[]
|
||||
jsDoc: string
|
||||
doc: string
|
||||
params: { name: string; text: string }[]
|
||||
returns: string | null
|
||||
source: string
|
||||
}
|
||||
|
||||
interface RenderContext {
|
||||
scanRoot: string
|
||||
cache: Map<string, { sf: ts.SourceFile; text: string }>
|
||||
violations: string[]
|
||||
}
|
||||
|
||||
function load(ctx: RenderContext, rel: string): { sf: ts.SourceFile; text: string } {
|
||||
const cached = ctx.cache.get(rel)
|
||||
if (cached !== undefined) return cached
|
||||
const text = readFileSync(resolve(ctx.scanRoot, rel), 'utf8')
|
||||
const entry = { sf: ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true), text }
|
||||
ctx.cache.set(rel, entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
function sourceJsDoc(text: string, sf: ts.SourceFile, node: ts.Node): string {
|
||||
const raw = rawJsDoc(text, node)
|
||||
if (raw === '') return ''
|
||||
const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf))
|
||||
const lineStart = sf.getPositionOfLineAndCharacter(line, 0)
|
||||
const indent = text.slice(lineStart, node.getStart(sf))
|
||||
return raw.split('\n')
|
||||
.map((sourceLine, index) => index > 0 && sourceLine.startsWith(indent)
|
||||
? sourceLine.slice(indent.length)
|
||||
: sourceLine)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function signatureOf(member: ts.Node, sf: ts.SourceFile): string {
|
||||
const full = member.getText(sf)
|
||||
const tail = (member as { body?: ts.Node; initializer?: ts.Node }).body
|
||||
?? (member as { initializer?: ts.Node }).initializer
|
||||
const signature = tail
|
||||
? full.slice(0, full.length - tail.getText(sf).length).replace(/[=\s]+$/, '')
|
||||
: full
|
||||
return signature.replace(/\s*;?\s*$/, '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function headingParams(parameters: readonly ts.ParameterDeclaration[], sf: ts.SourceFile): string {
|
||||
const names = parameters
|
||||
.filter(parameter => !(ts.isIdentifier(parameter.name) && parameter.name.text === 'this'))
|
||||
.map((parameter) => {
|
||||
const rest = parameter.dotDotDotToken ? '...' : ''
|
||||
const optional = parameter.questionToken || parameter.initializer ? '?' : ''
|
||||
return `${rest}${parameter.name.getText(sf)}${optional}`
|
||||
})
|
||||
return `(${names.join(', ')})`
|
||||
}
|
||||
|
||||
function isPublicInstance(member: ts.ClassElement): boolean {
|
||||
const modifiers = ts.getCombinedModifierFlags(member)
|
||||
if (modifiers & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected | ts.ModifierFlags.Static)) return false
|
||||
if (!member.name || ts.isComputedPropertyName(member.name) || ts.isPrivateIdentifier(member.name)) return false
|
||||
return !member.name.getText().startsWith('_')
|
||||
}
|
||||
|
||||
function isPublicStatic(member: ts.ClassElement): boolean {
|
||||
const modifiers = ts.getCombinedModifierFlags(member)
|
||||
if (modifiers & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected)) return false
|
||||
if (!(modifiers & ts.ModifierFlags.Static)) return false
|
||||
if (!member.name || ts.isComputedPropertyName(member.name) || ts.isPrivateIdentifier(member.name)) return false
|
||||
return !member.name.getText().startsWith('_')
|
||||
}
|
||||
|
||||
type Member = ts.MethodDeclaration
|
||||
| ts.MethodSignature
|
||||
| ts.PropertyDeclaration
|
||||
| ts.PropertySignature
|
||||
| ts.GetAccessorDeclaration
|
||||
|
||||
function memberDoc(ctx: RenderContext, where: string, name: string, group: Member[], rel: string): MemberDoc {
|
||||
const { sf, text } = load(ctx, rel)
|
||||
const first = group[0]
|
||||
if (first === undefined) throw new Error(`cordis-core-api: empty member group for ${name}.`)
|
||||
const rawDocs = group.map(member => sourceJsDoc(text, sf, member))
|
||||
const docIndex = rawDocs.findIndex(raw => parseJsDoc(raw).doc !== '')
|
||||
const raw = docIndex === -1 ? '' : (rawDocs[docIndex] ?? '')
|
||||
const doc = parseJsDoc(raw).doc
|
||||
if (doc === '') ctx.violations.push(`${where} has no JSDoc prose.`)
|
||||
const { params: tags, returns } = parseTags(raw)
|
||||
const functionMembers = group.filter((member): member is ts.MethodDeclaration | ts.MethodSignature =>
|
||||
ts.isMethodDeclaration(member) || ts.isMethodSignature(member))
|
||||
const docCarrier = functionMembers[docIndex === -1 ? 0 : docIndex]
|
||||
const params: { name: string; text: string }[] = []
|
||||
if (docCarrier !== undefined) {
|
||||
checkParams(where, 'cordis-core-api', docCarrier.parameters, tags, sf,
|
||||
parameter => ts.isIdentifier(parameter.name) && parameter.name.text === 'this', ctx.violations)
|
||||
if (docCarrier.type !== undefined) {
|
||||
checkReturns(where, docCarrier.type, returns, sf, ctx.violations)
|
||||
} else if (returns === null && ts.isMethodDeclaration(docCarrier)) {
|
||||
ctx.violations.push(`${where} has no return type annotation; document the result with @returns.`)
|
||||
}
|
||||
for (const parameter of docCarrier.parameters) {
|
||||
if (!ts.isIdentifier(parameter.name) || parameter.name.text === 'this') continue
|
||||
const text = tags.get(parameter.name.text)
|
||||
if (text !== undefined) params.push({ name: parameter.name.text, text })
|
||||
}
|
||||
}
|
||||
const headingSource = docCarrier ?? functionMembers[0]
|
||||
const signatures = ts.isMethodDeclaration(first) && functionMembers.length > 1
|
||||
? functionMembers.filter(member => ts.isMethodDeclaration(member) && member.body === undefined)
|
||||
: group
|
||||
return {
|
||||
name,
|
||||
heading: headingSource === undefined ? '' : headingParams(headingSource.parameters, sf),
|
||||
signatures: signatures.map(member => signatureOf(member, sf)),
|
||||
jsDoc: raw,
|
||||
doc,
|
||||
params,
|
||||
returns,
|
||||
source: pointer(rel, sf, first),
|
||||
}
|
||||
}
|
||||
|
||||
function heritageMembers(
|
||||
statement: ts.InterfaceDeclaration,
|
||||
sf: ts.SourceFile,
|
||||
groups: Map<string, (ts.MethodSignature | ts.PropertySignature | ts.MethodDeclaration)[]>,
|
||||
): void {
|
||||
for (const clause of statement.heritageClauses ?? []) {
|
||||
for (const type of clause.types) {
|
||||
if (!ts.isIdentifier(type.expression) || type.expression.text !== 'Pick') continue
|
||||
const [target, keys] = type.typeArguments ?? []
|
||||
if (target === undefined || keys === undefined || !ts.isTypeReferenceNode(target)) continue
|
||||
const targetName = target.typeName.getText(sf)
|
||||
const cls = sf.statements.find(
|
||||
(entry): entry is ts.ClassDeclaration => ts.isClassDeclaration(entry) && entry.name?.text === targetName,
|
||||
)
|
||||
if (cls === undefined) continue
|
||||
const picked = new Set<string>()
|
||||
const collect = (node: ts.TypeNode): void => {
|
||||
if (ts.isLiteralTypeNode(node) && ts.isStringLiteral(node.literal)) picked.add(node.literal.text)
|
||||
if (ts.isUnionTypeNode(node)) node.types.forEach(collect)
|
||||
}
|
||||
collect(keys)
|
||||
for (const member of cls.members) {
|
||||
if (!ts.isMethodDeclaration(member)) continue
|
||||
const name = member.name.getText(sf)
|
||||
if (!picked.has(name)) continue
|
||||
const group = groups.get(name) ?? []
|
||||
group.push(member)
|
||||
groups.set(name, group)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function contextMergeMembers(ctx: RenderContext, rel: string): MemberDoc[] {
|
||||
const { sf } = load(ctx, rel)
|
||||
const body = cordisModuleBody(sf)
|
||||
if (body === null) throw new Error(`cordis-core-api: ${rel} has no Context module merge.`)
|
||||
const groups = new Map<string, (ts.MethodSignature | ts.PropertySignature | ts.MethodDeclaration)[]>()
|
||||
for (const statement of body.statements) {
|
||||
if (!ts.isInterfaceDeclaration(statement) || statement.name.text !== 'Context') continue
|
||||
heritageMembers(statement, sf, groups)
|
||||
for (const member of statement.members) {
|
||||
if (!ts.isMethodSignature(member) && !ts.isPropertySignature(member)) continue
|
||||
if (ts.isComputedPropertyName(member.name)) continue
|
||||
const name = member.name.getText(sf)
|
||||
const group = groups.get(name) ?? []
|
||||
group.push(member)
|
||||
groups.set(name, group)
|
||||
}
|
||||
}
|
||||
return [...groups.entries()].map(([name, group]) =>
|
||||
memberDoc(ctx, `ctx.${name} (${rel})`, name, group, rel))
|
||||
}
|
||||
|
||||
function classMembers(ctx: RenderContext, rel: string, className: string): {
|
||||
doc: string
|
||||
instance: MemberDoc[]
|
||||
statics: MemberDoc[]
|
||||
source: string
|
||||
} {
|
||||
const { sf, text } = load(ctx, rel)
|
||||
const cls = sf.statements.find(
|
||||
(statement): statement is ts.ClassDeclaration =>
|
||||
ts.isClassDeclaration(statement) && statement.name?.text === className,
|
||||
)
|
||||
if (cls === undefined) throw new Error(`cordis-core-api: class ${className} not found in ${rel}.`)
|
||||
const doc = parseJsDoc(rawJsDoc(text, cls)).doc
|
||||
if (doc === '') ctx.violations.push(`class ${className} (${pointer(rel, sf, cls)}) has no JSDoc.`)
|
||||
const instance = new Map<string, Member[]>()
|
||||
const statics = new Map<string, Member[]>()
|
||||
for (const member of cls.members) {
|
||||
if (!ts.isMethodDeclaration(member) && !ts.isPropertyDeclaration(member) && !ts.isGetAccessorDeclaration(member)) continue
|
||||
const name = member.name.getText(sf)
|
||||
if (isPublicInstance(member)) {
|
||||
const group = instance.get(name) ?? []
|
||||
group.push(member)
|
||||
instance.set(name, group)
|
||||
} else if (isPublicStatic(member) && !ts.isGetAccessorDeclaration(member)) {
|
||||
const group = statics.get(name) ?? []
|
||||
group.push(member)
|
||||
statics.set(name, group)
|
||||
}
|
||||
}
|
||||
const declaration = sf.statements.find(
|
||||
(statement): statement is ts.InterfaceDeclaration =>
|
||||
ts.isInterfaceDeclaration(statement) && statement.name.text === className,
|
||||
)
|
||||
for (const member of declaration?.members ?? []) {
|
||||
if (!ts.isPropertySignature(member) || ts.isComputedPropertyName(member.name)) continue
|
||||
const name = member.name.getText(sf)
|
||||
const group = instance.get(name) ?? []
|
||||
group.push(member)
|
||||
instance.set(name, group)
|
||||
}
|
||||
const render = (groups: Map<string, Member[]>, prefix: string): MemberDoc[] =>
|
||||
[...groups.entries()].map(([name, group]) => memberDoc(ctx, `${prefix}${name} (${rel})`, name, group, rel))
|
||||
return {
|
||||
doc,
|
||||
instance: render(instance, `${className}#`),
|
||||
statics: render(statics, `${className}.`),
|
||||
source: pointer(rel, sf, cls),
|
||||
}
|
||||
}
|
||||
|
||||
function stripBodies(node: ts.Node, sf: ts.SourceFile): string {
|
||||
const cuts: { start: number; end: number }[] = []
|
||||
const visit = (entry: ts.Node): void => {
|
||||
const functionLike = ts.isMethodDeclaration(entry)
|
||||
|| ts.isConstructorDeclaration(entry)
|
||||
|| ts.isFunctionDeclaration(entry)
|
||||
|| ts.isGetAccessorDeclaration(entry)
|
||||
|| ts.isSetAccessorDeclaration(entry)
|
||||
if (functionLike && entry.body !== undefined) {
|
||||
const signatureEnd = (entry.type ?? entry.parameters.at(-1) ?? entry).getEnd()
|
||||
cuts.push({ start: signatureEnd, end: entry.body.getEnd() })
|
||||
return
|
||||
}
|
||||
entry.forEachChild(visit)
|
||||
}
|
||||
visit(node)
|
||||
const base = node.getStart(sf)
|
||||
let output = node.getText(sf)
|
||||
for (const cut of cuts.sort((left, right) => right.start - left.start)) {
|
||||
const head = output.slice(0, cut.start - base)
|
||||
const between = output.slice(cut.start - base, cut.end - base)
|
||||
const bodyBrace = between.indexOf('{')
|
||||
output = head + between.slice(0, bodyBrace).trimEnd() + output.slice(cut.end - base)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
function declarationPaste(ctx: RenderContext, rel: string, symbol: string): { doc: string; code: string; source: string } {
|
||||
const { sf, text } = load(ctx, rel)
|
||||
const matches = sf.statements.filter((statement) => {
|
||||
const named = ts.isInterfaceDeclaration(statement)
|
||||
|| ts.isTypeAliasDeclaration(statement)
|
||||
|| ts.isClassDeclaration(statement)
|
||||
|| ts.isEnumDeclaration(statement)
|
||||
|| ts.isModuleDeclaration(statement)
|
||||
return named && statement.name?.getText(sf) === symbol
|
||||
})
|
||||
const first = matches[0]
|
||||
if (first === undefined) throw new Error(`cordis-core-api: declaration ${symbol} not found in ${rel}.`)
|
||||
const doc = parseJsDoc(sourceJsDoc(text, sf, first)).doc
|
||||
const code = matches.map((statement) => {
|
||||
const jsDoc = sourceJsDoc(text, sf, statement)
|
||||
const declaration = stripBodies(statement, sf).replace(/^export\s+(default\s+)?/, '')
|
||||
return jsDoc === '' ? declaration : `${jsDoc}\n${declaration}`
|
||||
}).join('\n\n')
|
||||
return { doc, code, source: pointer(rel, sf, first) }
|
||||
}
|
||||
|
||||
function sourceLink(source: string): string {
|
||||
const [file, line] = source.split(':')
|
||||
return `[Source](../../../${file}${line === undefined ? '' : `#L${line}`})`
|
||||
}
|
||||
|
||||
function unlink(text: string): string {
|
||||
return text.replace(/\{@link\s+([^}|\s]+)\s*(?:[|\s]\s*([^}]*))?\}/g, (_match, target: string, label?: string) => {
|
||||
const name = label?.trim()
|
||||
return name && name !== '' ? name : `\`${target}\``
|
||||
})
|
||||
}
|
||||
|
||||
function prose(doc: string): string[] {
|
||||
const paragraphs = unlink(doc)
|
||||
.split(/\n\s*\n/)
|
||||
.map(paragraph => paragraph.replace(/\s*\n\s*/g, ' ').trim())
|
||||
.filter(paragraph => paragraph !== '')
|
||||
return paragraphs.flatMap((paragraph, index) => index === 0 ? [paragraph] : ['', paragraph])
|
||||
}
|
||||
|
||||
function renderMember(prefix: string, member: MemberDoc): string[] {
|
||||
const lines = [`### ${prefix}${member.name}${member.heading}`, '', `\`\`\`${FENCE}`]
|
||||
if (member.jsDoc !== '') lines.push(member.jsDoc)
|
||||
lines.push(...member.signatures, '```', '')
|
||||
if (member.doc !== '') lines.push(...prose(member.doc), '')
|
||||
for (const parameter of member.params) lines.push(`- \`${parameter.name}\` — ${unlink(parameter.text)}`)
|
||||
if (member.params.length > 0) lines.push('')
|
||||
if (member.returns !== null && member.returns !== '') lines.push(`**Returns** ${unlink(member.returns)}`, '')
|
||||
lines.push(sourceLink(member.source), '')
|
||||
return lines
|
||||
}
|
||||
|
||||
/** Render one detailed Cordis core API page and reject undocumented members. */
|
||||
export function renderCordisCoreApiPage(
|
||||
page: CordisCoreApiPage,
|
||||
scanRoot: string = root,
|
||||
): string {
|
||||
const ctx: RenderContext = { scanRoot, cache: new Map(), violations: [] }
|
||||
const lines = [
|
||||
'<!-- Generated by scripts/gen-cordis-catalog.ts — do not edit by hand.',
|
||||
' Run `pnpm run gen-cordis-catalog` to regenerate. -->',
|
||||
'',
|
||||
`# ${page.title}`,
|
||||
'',
|
||||
page.intro,
|
||||
'',
|
||||
]
|
||||
for (const section of page.sections) {
|
||||
if (section.kind !== 'decl' && section.heading !== undefined) lines.push(`## ${section.heading}`, '')
|
||||
if (section.kind === 'context-merge') {
|
||||
for (const member of contextMergeMembers(ctx, section.file)) lines.push(...renderMember('ctx.', member))
|
||||
} else if (section.kind === 'class') {
|
||||
const cls = classMembers(ctx, section.file, section.symbol)
|
||||
if (cls.doc !== '') lines.push(...prose(cls.doc), '')
|
||||
lines.push(sourceLink(cls.source), '')
|
||||
const prefix = section.prefix ?? `${section.symbol.toLowerCase()}.`
|
||||
for (const member of cls.instance) lines.push(...renderMember(prefix, member))
|
||||
if (cls.statics.length > 0) {
|
||||
lines.push('## Static members', '')
|
||||
for (const member of cls.statics) lines.push(...renderMember(`${section.symbol}.`, member))
|
||||
}
|
||||
} else {
|
||||
const declaration = declarationPaste(ctx, section.file, section.symbol)
|
||||
lines.push(`## ${section.symbol}`, '')
|
||||
if (declaration.doc !== '') lines.push(...prose(declaration.doc), '')
|
||||
lines.push(`\`\`\`${FENCE}`, declaration.code, '```', '', sourceLink(declaration.source), '')
|
||||
}
|
||||
}
|
||||
reportViolations('gen-cordis-catalog', ctx.violations)
|
||||
return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`
|
||||
}
|
||||
|
||||
/** Render every detailed Cordis core API page. */
|
||||
export function renderCordisCoreApiPages(scanRoot: string = root): Map<string, string> {
|
||||
return new Map(CORDIS_CORE_API_PAGES.map(page => [page.out, renderCordisCoreApiPage(page, scanRoot)]))
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
/**
|
||||
* Shared AST walkers for the cordis documentation generators
|
||||
* (`gen-cordis-catalog.ts`, `gen-website-api.ts`): locating the cordis module
|
||||
* merge in a source file, enumerating its `interface Events` members, and
|
||||
* resolving the `interface Context` service keys to their service classes.
|
||||
* One walk, two renderers — the catalog and the website page carry different
|
||||
* prose but must agree on WHAT exists.
|
||||
* AST walkers for the Cordis catalog generator: locate the Cordis module merge
|
||||
* in a source file, enumerate its `interface Events` members, and resolve the
|
||||
* `interface Context` service keys to their service classes.
|
||||
*/
|
||||
|
||||
import ts from 'typescript'
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
/**
|
||||
* Boot the REPL, TUI, or ACP Code Mode overlay, defaulting to REPL. Each overlay
|
||||
* Boot the TUI or ACP Code Mode overlay, defaulting to TUI. Each overlay
|
||||
* includes its base example, selects Code Mode, and adds the worker runtime.
|
||||
* All require a DeepSeek API key; unsupported arguments fail with usage.
|
||||
*/
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
// Each UI's node invocation, verbatim what its base demo script runs plus
|
||||
// the overlay config (the stdio bin keeps --expose-internals for the cordis
|
||||
// Loader's HMR path).
|
||||
// Each UI's node invocation matches its base demo script plus the overlay config.
|
||||
const UIS = new Map([
|
||||
['repl', ['--expose-internals', '--import', 'tsx', 'packages/examples/stdio-demo/src/bin.ts', 'examples/repl-agent/code-mode.cordis.yml']],
|
||||
['tui', ['--expose-internals', '--import', 'tsx', 'packages/examples/stdio-demo/src/bin.ts', 'examples/tui-agent/code-mode.cordis.yml']],
|
||||
['tui', ['--expose-internals', '--import', 'tsx', 'packages/examples/tui-demo/src/bin.ts', 'examples/tui-agent/code-mode.cordis.yml']],
|
||||
['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/code-mode.cordis.yml']],
|
||||
])
|
||||
|
||||
const ui = process.argv[2] ?? 'repl'
|
||||
const ui = process.argv[2] ?? 'tui'
|
||||
const args = UIS.get(ui)
|
||||
if (!args || process.argv.length > 3) {
|
||||
console.error('usage: pnpm run demo:code-mode [repl|tui|acp]')
|
||||
console.error('usage: pnpm run demo:code-mode [tui|acp]')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
|
||||
17
scripts/doc-typecheck-paths.spec.ts
Normal file
17
scripts/doc-typecheck-paths.spec.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { builtDeclarationPath } from './doc-typecheck-paths.ts'
|
||||
|
||||
describe('builtDeclarationPath', () => {
|
||||
it('maps package source directories and exact entry files to built declarations', () => {
|
||||
expect(builtDeclarationPath('./packages/*/*/src')).toBe('./packages/*/*/lib/types')
|
||||
expect(builtDeclarationPath('./packages/support/invariants/src/index.ts'))
|
||||
.toBe('./packages/support/invariants/lib/types/index.d.ts')
|
||||
expect(builtDeclarationPath('./packages/core/session/src/invariant.ts'))
|
||||
.toBe('./packages/core/session/lib/types/invariant.d.ts')
|
||||
})
|
||||
|
||||
it('rejects aliases without a supported source target', () => {
|
||||
expect(() => builtDeclarationPath('./packages/support/invariants/source/index.ts'))
|
||||
.toThrow('cannot map workspace source path')
|
||||
})
|
||||
})
|
||||
22
scripts/doc-typecheck-paths.ts
Normal file
22
scripts/doc-typecheck-paths.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/** Map one workspace source alias target to its declaration-build target. */
|
||||
export function builtDeclarationPath(candidate: string): string {
|
||||
// Two workspace shapes exist: whole-package entries end in /src, subpath
|
||||
// wildcards (apiproxy's browser-safe /api and /client channels) in /src/*.
|
||||
if (candidate.endsWith('/src')) {
|
||||
return `${candidate.slice(0, -'/src'.length)}/lib/types`
|
||||
}
|
||||
if (candidate.endsWith('/src/*')) {
|
||||
return `${candidate.slice(0, -'/src/*'.length)}/lib/types/*`
|
||||
}
|
||||
const sourceFile = /^(.*)\/src\/(.+)\.ts$/.exec(candidate)
|
||||
if (sourceFile?.[1] && sourceFile[2]) {
|
||||
return `${sourceFile[1]}/lib/types/${sourceFile[2]}.d.ts`
|
||||
}
|
||||
// Directory subpath entries (web-react's /store, runtime's /client): the
|
||||
// source dir maps to the same dir under lib/types (index resolution applies).
|
||||
const sourceDir = /^(.*)\/src\/(.+)$/.exec(candidate)
|
||||
if (sourceDir?.[1] && sourceDir[2]) {
|
||||
return `${sourceDir[1]}/lib/types/${sourceDir[2]}`
|
||||
}
|
||||
throw new Error(`doc-typecheck: cannot map workspace source path to built declarations: ${candidate}`)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { execFileSync } from 'node:child_process'
|
||||
import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join, relative, resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { builtDeclarationPath } from './doc-typecheck-paths.ts'
|
||||
import { extractFences } from './md-fences.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
@@ -65,12 +66,7 @@ function builtTypeCompilerOptions(): ts.CompilerOptions {
|
||||
if (parsed.options.paths === undefined) throw new Error('doc-typecheck: root tsconfig has no workspace paths')
|
||||
const paths = Object.fromEntries(Object.entries(parsed.options.paths).map(([specifier, candidates]) => [
|
||||
specifier,
|
||||
candidates.map((candidate) => {
|
||||
if (!candidate.endsWith('/src')) {
|
||||
throw new Error(`doc-typecheck: cannot map workspace source path to built declarations: ${candidate}`)
|
||||
}
|
||||
return `${candidate.slice(0, -'/src'.length)}/lib/types`
|
||||
}),
|
||||
candidates.map(builtDeclarationPath),
|
||||
]))
|
||||
const options: ts.CompilerOptions = {
|
||||
...parsed.options,
|
||||
@@ -192,7 +188,7 @@ function remapBlockPaths(output: string, blocks: Block[]): string {
|
||||
})
|
||||
}
|
||||
|
||||
const markdownGlobs = ['README.md', '.agents/notes/**/*.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'website/zh-CN/**/*.md']
|
||||
const markdownGlobs = ['README.md', '.agents/notes/**/*.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md']
|
||||
|
||||
const files: string[] = []
|
||||
for (const pattern of markdownGlobs) {
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
* curated table below. `--check` verifies both committed artifacts.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import { globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { renderCordisCoreApiPages } from './cordis-core-api.ts'
|
||||
import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts'
|
||||
import { cordisModuleBody, eventMembers, serviceClasses } from './cordis-walk.ts'
|
||||
|
||||
@@ -27,6 +28,7 @@ const FENCE = 'ts cordis-catalog'
|
||||
*/
|
||||
export const LINK_MAP: Record<string, string> = {
|
||||
Agent: 'core.md',
|
||||
AgentCancelCause: 'core.md',
|
||||
AgentOptions: 'core.md',
|
||||
AgentStatus: 'core.md',
|
||||
ContentBlock: 'core.md',
|
||||
@@ -34,6 +36,8 @@ export const LINK_MAP: Record<string, string> = {
|
||||
ContinuationStop: 'core.md',
|
||||
GenerateOptions: 'core.md',
|
||||
LlmCallConfig: 'core.md',
|
||||
LlmModelContext: 'core.md',
|
||||
LlmFailure: 'llm-streaming.md',
|
||||
LlmModelInfo: 'core.md',
|
||||
LlmProviderInfo: 'core.md',
|
||||
Message: 'core.md',
|
||||
@@ -57,6 +61,7 @@ export const LINK_MAP: Record<string, string> = {
|
||||
CodeRunResult: 'code-runtime.md',
|
||||
CompactionResult: 'compaction.md',
|
||||
CompactionTrigger: 'compaction.md',
|
||||
PruneResult: 'compaction.md',
|
||||
FileReadOutcome: 'filesystem.md',
|
||||
FsDirEntry: 'filesystem.md',
|
||||
FsEditOutcome: 'filesystem.md',
|
||||
@@ -68,6 +73,16 @@ export const LINK_MAP: Record<string, string> = {
|
||||
FsVersion: 'filesystem.md',
|
||||
FsWriteIntent: 'filesystem.md',
|
||||
FsWriteOutcome: 'filesystem.md',
|
||||
CreateGoalRequest: 'goal.md',
|
||||
EditGoalRequest: 'goal.md',
|
||||
GoalBlockReason: 'goal.md',
|
||||
GoalChanged: 'goal.md',
|
||||
GoalRef: 'goal.md',
|
||||
GoalView: 'goal.md',
|
||||
CommandDefinition: 'commands.md',
|
||||
CommandDescriptor: 'commands.md',
|
||||
CommandResult: 'commands.md',
|
||||
CommandSurface: 'commands.md',
|
||||
LlmAdapter: 'llm-streaming.md',
|
||||
LlmService: 'llm-streaming.md',
|
||||
StreamChunk: 'llm-streaming.md',
|
||||
@@ -91,8 +106,11 @@ export const LINK_MAP: Record<string, string> = {
|
||||
ScopeKey: 'scope.md',
|
||||
Scoped: 'scope.md',
|
||||
EpochHeader: 'session.md',
|
||||
OutOfBandSessionEventType: 'session.md',
|
||||
Session: 'session.md',
|
||||
SessionEventMap: 'session.md',
|
||||
TurnEndReason: 'session.md',
|
||||
TurnTrigger: 'session.md',
|
||||
SessionEventReadRequest: 'session-query.md',
|
||||
SessionEventRecord: 'session-query.md',
|
||||
SessionEventTrace: 'session-query.md',
|
||||
@@ -100,6 +118,8 @@ export const LINK_MAP: Record<string, string> = {
|
||||
SessionEventWindow: 'session-query.md',
|
||||
SessionLineageTrace: 'session-query.md',
|
||||
SessionRecord: 'session-query.md',
|
||||
SessionTitleProvider: 'session-title.md',
|
||||
SessionTitleSnapshot: 'session-title.md',
|
||||
SkillDefinition: 'skills.md',
|
||||
SkillLookupOptions: 'skills.md',
|
||||
SkillProvider: 'skills.md',
|
||||
@@ -125,6 +145,7 @@ export const LINK_MAP: Record<string, string> = {
|
||||
PreToolDecision: 'tools.md',
|
||||
ToolDefinition: 'tools.md',
|
||||
ToolExecution: 'tools.md',
|
||||
ToolDispatchExecution: 'tools.md',
|
||||
ToolExecutionInput: 'tools.md',
|
||||
ToolExecutionMode: 'tools.md',
|
||||
ToolExecutionResult: 'tools.md',
|
||||
@@ -166,6 +187,11 @@ const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
BashEnvVariableInfo: 'service-local metadata type is owned by packages/bash/tool-bash/src/index.ts',
|
||||
CompactAgentContext: 'compaction service input is owned by packages/compact/compact/src/index.ts',
|
||||
CreateAgentOptions: 'agent creation contract is owned by packages/core/agent/README.md',
|
||||
InvariantInstaller: 'service-local contribution contract is owned by packages/support/invariants/README.md',
|
||||
LocaleDict: 'service-local dictionary shape is owned by packages/client/i18n/src/index.ts',
|
||||
ThemeTokens: 'service-local token dictionary is owned by packages/client/ui-theme/src/index.ts',
|
||||
Translate: 'service-local bound translator is owned by packages/client/i18n/src/index.ts',
|
||||
InvariantRegistration: 'service-local lifecycle handle is owned by packages/support/invariants/README.md',
|
||||
PresetOption: 'deployment menu metadata is owned by packages/ui/permission/README.md',
|
||||
PresetSpec: 'deployment preset composition is owned by packages/ui/permission/README.md',
|
||||
PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md',
|
||||
@@ -276,8 +302,7 @@ interface InheritedEntry {
|
||||
source: string
|
||||
}
|
||||
|
||||
// cordisModuleBody / eventMembers / serviceClasses live in cordis-walk.ts,
|
||||
// shared with gen-website-api.ts — one walk, two renderers.
|
||||
// cordisModuleBody / eventMembers / serviceClasses live in cordis-walk.ts.
|
||||
|
||||
/** The signature text of a method-signature member (everything but a body). */
|
||||
function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.SourceFile): string {
|
||||
@@ -515,7 +540,7 @@ export function renderEvents(events: EventEntry[]): string {
|
||||
'',
|
||||
GATE_NOTICE,
|
||||
'',
|
||||
'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely.',
|
||||
'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely. The event-dispatch methods themselves are generated in the [Cordis core Events API](core/events.md).',
|
||||
'',
|
||||
'Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`).',
|
||||
'',
|
||||
@@ -550,7 +575,7 @@ export function renderServices(services: ServiceEntry[]): string {
|
||||
'',
|
||||
GATE_NOTICE,
|
||||
'',
|
||||
'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer `ctx` surface a plugin also sees — pinned vendor source, summarized tersely.',
|
||||
'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer `ctx` surface a plugin also sees — pinned vendor source, summarized tersely. Detailed Context, Fiber, Registry, and Service APIs are generated in the [Cordis core API](core/context.md).',
|
||||
'',
|
||||
]
|
||||
for (const s of services) lines.push(...renderService(s))
|
||||
@@ -574,6 +599,7 @@ function main(): void {
|
||||
const outputs: [string, string][] = [
|
||||
[OUT_EVENTS, renderEvents(collectEvents())],
|
||||
[OUT_SERVICES, renderServices(collectServices())],
|
||||
...renderCordisCoreApiPages(),
|
||||
]
|
||||
if (process.argv.includes('--check')) {
|
||||
const stale: string[] = []
|
||||
@@ -590,15 +616,19 @@ function main(): void {
|
||||
if (committed !== content) stale.push(out)
|
||||
}
|
||||
if (stale.length === 0) {
|
||||
console.log(`gen-cordis-catalog: ${OUT_EVENTS} and ${OUT_SERVICES} are up to date.`)
|
||||
console.log(`gen-cordis-catalog: ${outputs.length} generated file(s) are up to date.`)
|
||||
process.exit(0)
|
||||
}
|
||||
console.error(`gen-cordis-catalog: ${stale.join(' and ')} ${stale.length === 1 ? 'is' : 'are'} stale. Run \`pnpm run gen-cordis-catalog\` and commit the result.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
for (const [out, content] of outputs) writeFileSync(resolve(root, out), content)
|
||||
console.log(`gen-cordis-catalog: wrote ${OUT_EVENTS} and ${OUT_SERVICES}.`)
|
||||
for (const [out, content] of outputs) {
|
||||
const destination = resolve(root, out)
|
||||
mkdirSync(dirname(destination), { recursive: true })
|
||||
writeFileSync(destination, content)
|
||||
}
|
||||
console.log(`gen-cordis-catalog: wrote ${outputs.length} generated file(s).`)
|
||||
}
|
||||
|
||||
// Run only when invoked as a script, not when imported by a test.
|
||||
|
||||
@@ -58,6 +58,7 @@ const GROUP_ORDER = [
|
||||
'util',
|
||||
'llm',
|
||||
'core',
|
||||
'goal',
|
||||
'bash',
|
||||
'pty',
|
||||
'sandbox',
|
||||
@@ -70,10 +71,12 @@ const GROUP_ORDER = [
|
||||
'web',
|
||||
'spill',
|
||||
'todo',
|
||||
'plan',
|
||||
'cordis',
|
||||
'hooks',
|
||||
'session-persistence',
|
||||
'session-query',
|
||||
'session-title',
|
||||
'support',
|
||||
'ui',
|
||||
]
|
||||
@@ -96,14 +99,30 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
consumers: ['compact-basic'],
|
||||
note: 'Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements.',
|
||||
},
|
||||
{
|
||||
key: 'toolResultPrune',
|
||||
pkg: 'compact-tool-result-prune',
|
||||
title: 'Model-free tool-result pruning',
|
||||
mode: 'core',
|
||||
consumers: ['compact-basic'],
|
||||
note: 'Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction.',
|
||||
},
|
||||
{
|
||||
key: 'sessions',
|
||||
pkg: 'session',
|
||||
title: 'In-memory session store',
|
||||
mode: 'core',
|
||||
consumers: ['agent-loop', 'agent', 'cli-demo', 'session-persistence', 'session-query', 'subagent-inprocess', 'invariants'],
|
||||
consumers: ['agent-loop', 'agent', 'cli-demo', 'session-persistence', 'session-query', 'subagent-inprocess'],
|
||||
note: 'Owns append-only Session instances and emits the durable session event feed.',
|
||||
},
|
||||
{
|
||||
key: 'invariants',
|
||||
pkg: 'invariants',
|
||||
title: 'Package-owned invariant registry',
|
||||
mode: 'core',
|
||||
consumers: ['session', 'agent', 'scope', 'agent-loop'],
|
||||
note: 'Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures.',
|
||||
},
|
||||
{
|
||||
key: 'sessionPersistence',
|
||||
pkg: 'session-persistence',
|
||||
@@ -120,6 +139,14 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
mode: 'seam',
|
||||
note: 'Resolves live and optional persisted logs into one logical corpus for exact reads and relationship traces.',
|
||||
},
|
||||
{
|
||||
key: 'sessionTitle',
|
||||
pkg: 'session-title',
|
||||
title: 'Log-backed session titles',
|
||||
mode: 'seam',
|
||||
implementations: ['session-title-first-message-llm', 'session-title-all-messages-llm'],
|
||||
note: 'Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration.',
|
||||
},
|
||||
{
|
||||
key: 'systemPrompt',
|
||||
pkg: 'system-prompt',
|
||||
@@ -141,10 +168,26 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
pkg: 'user-interaction',
|
||||
title: 'Human question/answer seam',
|
||||
mode: 'seam',
|
||||
implementations: ['stdio-demo', 'acp'],
|
||||
consumers: ['tool-ask-user', 'stdio-demo', 'acp'],
|
||||
implementations: ['tui', 'acp'],
|
||||
consumers: ['tool-ask-user', 'tui', '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: 'planMode',
|
||||
pkg: 'plan-mode',
|
||||
title: 'Plan collaboration state',
|
||||
mode: 'core',
|
||||
consumers: ['acp'],
|
||||
note: 'Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions.',
|
||||
},
|
||||
{
|
||||
key: 'commands',
|
||||
pkg: 'commands',
|
||||
title: 'Human command registry',
|
||||
mode: 'core',
|
||||
consumers: ['tui', 'acp'],
|
||||
note: 'Plugins register direct human commands; TUI and ACP consume the same effective per-agent catalog without sending invocations to the model.',
|
||||
},
|
||||
{
|
||||
key: 'skills',
|
||||
pkg: 'skill',
|
||||
@@ -159,7 +202,7 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
pkg: 'agent',
|
||||
title: 'Agent service',
|
||||
mode: 'core',
|
||||
consumers: ['agent-loop', 'acp', 'cli-demo', 'subagent-inprocess', 'stdio-demo', 'invariants'],
|
||||
consumers: ['agent-loop', 'acp', 'cli-demo', 'subagent-inprocess', 'tui-demo'],
|
||||
note: 'Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation.',
|
||||
},
|
||||
{
|
||||
@@ -170,6 +213,13 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
consumers: ['agent-spine-demo'],
|
||||
note: 'The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package.',
|
||||
},
|
||||
{
|
||||
key: 'goals',
|
||||
pkg: 'goal',
|
||||
title: 'Same-session goal domain',
|
||||
mode: 'core',
|
||||
note: 'Folds revisioned objective state from the session log and keeps live continuation activation process-local.',
|
||||
},
|
||||
{
|
||||
key: 'bash',
|
||||
pkg: 'bash',
|
||||
@@ -265,8 +315,8 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
title: 'Subagent provider registry',
|
||||
mode: 'seam',
|
||||
implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp'],
|
||||
consumers: ['tool-subagent'],
|
||||
note: 'Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name.',
|
||||
consumers: ['tool-subagent', 'tool-ralph'],
|
||||
note: 'Providers implement transports; tool-subagent exposes configured delegation while tool-ralph requires one fresh structured-output route.',
|
||||
},
|
||||
{
|
||||
key: 'tasks',
|
||||
@@ -300,8 +350,8 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
title: 'Workflow script engine',
|
||||
mode: 'seam',
|
||||
implementations: ['workflow-workerthread'],
|
||||
consumers: ['tool-workflow'],
|
||||
note: 'One engine per context (bash shape, no named-provider registry); the worker-thread engine fans agent() calls out through ctx.subagents.',
|
||||
consumers: ['tool-workflow', 'tool-ralph'],
|
||||
note: 'One engine per context (bash shape, no named-provider registry); the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents.',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -437,29 +487,13 @@ function stripYamlScalar(value: string): string {
|
||||
}
|
||||
|
||||
const APP_EXAMPLES = [
|
||||
{
|
||||
id: 'echo',
|
||||
rel: 'examples/echo-agent/composition.md',
|
||||
title: 'Echo Agent App Composition',
|
||||
label: 'examples/echo-agent',
|
||||
config: 'examples/echo-agent/cordis.yml',
|
||||
summary: 'The echo demo swaps in a local mock LLM and teaching echo tool, then loads the stdio app package for the shared spine and terminal front door.',
|
||||
},
|
||||
{
|
||||
id: 'repl',
|
||||
rel: 'examples/repl-agent/composition.md',
|
||||
title: 'REPL Agent App Composition',
|
||||
label: 'examples/repl-agent',
|
||||
config: 'examples/repl-agent/cordis.yml',
|
||||
summary: 'The REPL agent demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package.',
|
||||
},
|
||||
{
|
||||
id: 'tui',
|
||||
rel: 'examples/tui-agent/composition.md',
|
||||
title: 'TUI Agent App Composition',
|
||||
label: 'examples/tui-agent',
|
||||
config: 'examples/tui-agent/cordis.yml',
|
||||
summary: 'The TUI agent reuses the repl-agent backend and tool composition while fixing the shared terminal app to the full-screen dsh-tui front door.',
|
||||
summary: 'The TUI agent combines the real DeepSeek adapter, coding tools, compaction, subagents, and workflows with the full-screen terminal app package.',
|
||||
},
|
||||
{
|
||||
id: 'headless',
|
||||
@@ -489,18 +523,13 @@ const APP_EXAMPLES = [
|
||||
|
||||
type AppExample = typeof APP_EXAMPLES[number]
|
||||
|
||||
function renderAppExpansion(lines: string[], appNode: string, pluginName: string, exampleId: string): void {
|
||||
function renderAppExpansion(lines: string[], appNode: string, pluginName: string): void {
|
||||
const agentCore = nodeId('bundle', 'agent_core')
|
||||
const jsonl = nodeId('bundle', 'jsonl')
|
||||
lines.push(` ${appNode} --> ${agentCore}["@deepseek-ai/dsh-agent-spine-demo"]`)
|
||||
lines.push(` ${appNode} --> ${jsonl}["@deepseek-ai/dsh-session-persistence-jsonl"]`)
|
||||
if (pluginName === '@deepseek-ai/dsh-stdio-demo') {
|
||||
const frontDoor = exampleId === 'tui'
|
||||
? '@deepseek-ai/dsh-tui<br/>pre-created main agent'
|
||||
: exampleId === 'repl'
|
||||
? '@deepseek-ai/dsh-stdio<br/>pre-created main agent'
|
||||
: 'dsh-tui (TTY) / dsh-stdio (pipes)<br/>pre-created main agent'
|
||||
lines.push(` ${appNode} --> ${nodeId('frontdoor', 'stdio')}["${frontDoor}"]`)
|
||||
if (pluginName === '@deepseek-ai/dsh-tui-demo') {
|
||||
lines.push(` ${appNode} --> ${nodeId('frontdoor', 'tui')}["@deepseek-ai/dsh-tui<br/>pre-created main agent"]`)
|
||||
} else if (pluginName === '@deepseek-ai/dsh-cli-demo') {
|
||||
lines.push(` ${appNode} --> ${nodeId('frontdoor', 'cli')}["one-shot driver<br/>format-pure stdout<br/>fresh top-level agent"]`)
|
||||
} else if (pluginName === '@deepseek-ai/dsh-acp-demo') {
|
||||
@@ -529,8 +558,8 @@ function renderAppComposition(example: AppExample): string {
|
||||
const pluginNode = nodeId(`plugin_${example.id}`, plugin.id)
|
||||
lines.push(` ${pluginNode}["${escLabel(plugin.id)}<br/>${escLabel(plugin.name)}"]`)
|
||||
lines.push(` cfg --> ${pluginNode}`)
|
||||
if (plugin.name === '@deepseek-ai/dsh-stdio-demo' || plugin.name === '@deepseek-ai/dsh-cli-demo' || plugin.name === '@deepseek-ai/dsh-acp-demo') {
|
||||
renderAppExpansion(lines, pluginNode, plugin.name, example.id)
|
||||
if (plugin.name === '@deepseek-ai/dsh-tui-demo' || plugin.name === '@deepseek-ai/dsh-cli-demo' || plugin.name === '@deepseek-ai/dsh-acp-demo') {
|
||||
renderAppExpansion(lines, pluginNode, plugin.name)
|
||||
}
|
||||
}
|
||||
lines.push(
|
||||
@@ -919,7 +948,7 @@ function renderLifecycle(): string {
|
||||
'',
|
||||
'The `assistant/message` edge records every successful provider call, including content-less and `max-tokens` finishes. Empty content stays out of derived history while the durable anchor retains usage and exact chunk provenance, including an explicit empty source set.',
|
||||
'',
|
||||
'`dsh-compact-basic` uses `agent/post-step` for pressure after those durable facts and `agent/request-error` only for canonical context overflow. Recovery compacts between the closed failed step and a fresh retry step, and returns retry only when the surface replacement generation advances; otherwise the original request error remains authoritative.',
|
||||
'`dsh-compact-basic` uses `agent/post-step` for pressure after those durable facts and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and a fresh retry step, and returns retry only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative.',
|
||||
'',
|
||||
'SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request shaping, steering, continuation, and errors.',
|
||||
'',
|
||||
@@ -1028,8 +1057,6 @@ function renderDocs(): GraphDoc[] {
|
||||
function renderIndex(docs: GraphDoc[]): string {
|
||||
const labels: Record<string, string> = {
|
||||
'docs/capability-seams.md': 'capability seams and core services',
|
||||
'examples/echo-agent/composition.md': 'echo-agent app composition',
|
||||
'examples/repl-agent/composition.md': 'repl-agent app composition',
|
||||
'examples/headless-agent/composition.md': 'headless-agent app composition',
|
||||
'examples/tui-agent/composition.md': 'tui-agent app composition',
|
||||
'examples/cordis-agent/composition.md': 'cordis-agent app composition',
|
||||
@@ -1041,8 +1068,6 @@ function renderIndex(docs: GraphDoc[]): string {
|
||||
}
|
||||
const modes: Record<string, string> = {
|
||||
'docs/capability-seams.md': 'hybrid generated',
|
||||
'examples/echo-agent/composition.md': 'hybrid generated',
|
||||
'examples/repl-agent/composition.md': 'hybrid generated',
|
||||
'examples/headless-agent/composition.md': 'hybrid generated',
|
||||
'examples/tui-agent/composition.md': 'hybrid generated',
|
||||
'examples/cordis-agent/composition.md': 'hybrid generated',
|
||||
|
||||
@@ -21,6 +21,7 @@ const GROUP_ORDER = [
|
||||
'util',
|
||||
'llm',
|
||||
'core',
|
||||
'goal',
|
||||
'bash',
|
||||
'fs',
|
||||
'skill',
|
||||
@@ -30,10 +31,12 @@ const GROUP_ORDER = [
|
||||
'spill',
|
||||
'timeout',
|
||||
'todo',
|
||||
'plan',
|
||||
'cordis',
|
||||
'hooks',
|
||||
'session-persistence',
|
||||
'session-query',
|
||||
'session-title',
|
||||
'support',
|
||||
'ui',
|
||||
]
|
||||
|
||||
@@ -41,6 +41,11 @@ const LINK_MAP: Record<string, string> = {
|
||||
TodoItem: 'session.md',
|
||||
TurnTrigger: 'session.md',
|
||||
TurnEndReason: 'session.md',
|
||||
SessionTitleEventData: 'session-title.md',
|
||||
SessionTitleLlmRequestEventData: 'session-title.md',
|
||||
SessionTitleModelProvenance: 'session-title.md',
|
||||
SessionTitleProviderId: 'session-title.md',
|
||||
SessionTitleSource: 'session-title.md',
|
||||
}
|
||||
|
||||
/** One log event, extracted from a `SessionEventMap` declaration. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Generate the dev-invariants scoped-event resolver map from the
|
||||
* repository TypeScript Program.
|
||||
* Generate dsh-scope's invariant resolver map from the repository TypeScript
|
||||
* Program.
|
||||
*
|
||||
* A scoped event declares `this: Scoped<Base>`. Real `scopeTarget(base, key)`
|
||||
* calls establish the routing-key type for that base. The generator searches
|
||||
@@ -20,7 +20,7 @@ import { pointer, rawJsDoc } from './jsdoc.ts'
|
||||
import { TypeScriptProject } from './ts-project.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'packages/support/invariants/src/scoped-events.generated.ts'
|
||||
const OUT = 'packages/core/scope/src/scoped-events.generated.ts'
|
||||
const SCOPE_DOC_MARKER = 'Scope-filtered dispatch'
|
||||
|
||||
interface ScopeTargetContract {
|
||||
@@ -39,7 +39,6 @@ interface SubjectCandidate {
|
||||
interface ScopedEventResolver {
|
||||
event: string
|
||||
candidate: SubjectCandidate | null
|
||||
ownerPackage: string
|
||||
}
|
||||
|
||||
interface ScopeTag {
|
||||
@@ -54,7 +53,6 @@ class ScopedEventGenerator {
|
||||
private readonly scopeTargetDeclaration: ts.FunctionDeclaration
|
||||
private readonly scopedSymbol: ts.Symbol
|
||||
private readonly violations: string[] = []
|
||||
private readonly packageNames = new Map<string, string>()
|
||||
|
||||
constructor(private readonly project: TypeScriptProject) {
|
||||
this.checker = project.checker
|
||||
@@ -81,44 +79,25 @@ class ScopedEventGenerator {
|
||||
+ this.violations.map(violation => ` - ${violation}`).join('\n'),
|
||||
)
|
||||
}
|
||||
const ownerImports = [...new Set(resolvers.map(resolver => resolver.ownerPackage))]
|
||||
.sort()
|
||||
.map(packageName => `import type {} from ${quote(packageName)}`)
|
||||
return [
|
||||
'/**',
|
||||
' * Generated scoped-event routing-subject resolvers for dsh-invariants.',
|
||||
' * Generated scoped-event routing-subject resolvers for dsh-scope invariants.',
|
||||
' * Do not edit by hand; run `pnpm run gen-scoped-events`.',
|
||||
' *',
|
||||
' * @module @deepseek-ai/dsh-invariants/scoped-events.generated',
|
||||
' * @module @deepseek-ai/dsh-scope/scoped-events.generated',
|
||||
' */',
|
||||
'',
|
||||
"import type { Events } from 'cordis'",
|
||||
"import type { Scoped } from '@deepseek-ai/dsh-scope'",
|
||||
...ownerImports,
|
||||
'',
|
||||
'type ScopedEventName = {',
|
||||
' [K in keyof Events]: ThisParameterType<Events[K]> extends Scoped<object> ? K : never',
|
||||
'}[keyof Events]',
|
||||
'',
|
||||
'type ScopedSubjectResolver = (args: readonly unknown[]) => unknown',
|
||||
'',
|
||||
'function adapt<K extends ScopedEventName>(',
|
||||
' resolver: (args: Parameters<Events[K]>) => unknown,',
|
||||
'): ScopedSubjectResolver {',
|
||||
' return args => resolver(args as Parameters<Events[K]>)',
|
||||
'}',
|
||||
'',
|
||||
'const scopedSubjectResolvers = Object.freeze({',
|
||||
'const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | null>> = Object.freeze({',
|
||||
...resolvers.map(({ event, candidate }) => {
|
||||
if (candidate === null) return ` '${event}': null,`
|
||||
const subject = candidate.property === undefined
|
||||
? `args[${candidate.parameter}]`
|
||||
: `args[${candidate.parameter}].${candidate.property}`
|
||||
return ` '${event}': adapt<'${event}'>(args => ${subject}),`
|
||||
: `(args[${candidate.parameter}] as Record<string, unknown>)[${quote(candidate.property)}]`
|
||||
return ` '${event}': args => ${subject},`
|
||||
}),
|
||||
'} as const satisfies Readonly<Record<ScopedEventName, ScopedSubjectResolver | null>>)',
|
||||
'',
|
||||
'const scopedSubjectResolverIndex: Readonly<Record<string, ScopedSubjectResolver | null>> = scopedSubjectResolvers',
|
||||
'})',
|
||||
'',
|
||||
'/**',
|
||||
' * Resolve the routing key named by one scoped event payload. A null',
|
||||
@@ -129,7 +108,7 @@ class ScopedEventGenerator {
|
||||
' * or undefined when the event is not scope-filtered.',
|
||||
' */',
|
||||
'export function scopedSubjectResolverFor(event: string): ScopedSubjectResolver | null | undefined {',
|
||||
' return scopedSubjectResolverIndex[event]',
|
||||
' return scopedSubjectResolvers[event]',
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
@@ -186,7 +165,6 @@ class ScopedEventGenerator {
|
||||
const resolvers: ScopedEventResolver[] = []
|
||||
for (const sourceFile of this.packageSources) {
|
||||
const rel = this.project.relativePath(sourceFile)
|
||||
const ownerPackage = this.packageName(packageRootFor(rel))
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isInterfaceDeclaration(node) && node.name.text === 'Events' && isCordisModuleInterface(node)) {
|
||||
for (const member of node.members) {
|
||||
@@ -232,7 +210,7 @@ class ScopedEventGenerator {
|
||||
+ 'add @dshScopeScan unsupported only when the key is intentionally absent from the payload',
|
||||
)
|
||||
}
|
||||
resolvers.push({ event, candidate: null, ownerPackage })
|
||||
resolvers.push({ event, candidate: null })
|
||||
continue
|
||||
}
|
||||
if (tag.unsupported) {
|
||||
@@ -241,7 +219,7 @@ class ScopedEventGenerator {
|
||||
)
|
||||
continue
|
||||
}
|
||||
resolvers.push({ event, candidate: candidates[0] ?? null, ownerPackage })
|
||||
resolvers.push({ event, candidate: candidates[0] ?? null })
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
@@ -311,19 +289,6 @@ class ScopedEventGenerator {
|
||||
return dedupeCandidates(candidates)
|
||||
}
|
||||
|
||||
/** Read and cache one workspace package name. */
|
||||
private packageName(packageRoot: string): string {
|
||||
const cached = this.packageNames.get(packageRoot)
|
||||
if (cached) return cached
|
||||
const manifest: unknown = JSON.parse(readFileSync(resolve(root, packageRoot, 'package.json'), 'utf8'))
|
||||
const name: unknown = typeof manifest === 'object' && manifest !== null
|
||||
? Reflect.get(manifest, 'name')
|
||||
: undefined
|
||||
if (typeof name !== 'string') throw new Error(`gen-scoped-events: ${packageRoot}/package.json has no name`)
|
||||
this.packageNames.set(packageRoot, name)
|
||||
return name
|
||||
}
|
||||
|
||||
/** Compare exact Program type identities after removing null and undefined. */
|
||||
private typesEquivalent(left: ts.Type, right: ts.Type): boolean {
|
||||
const normalizedLeft = this.normalizedType(left)
|
||||
@@ -398,13 +363,6 @@ function dedupeCandidates(candidates: readonly SubjectCandidate[]): SubjectCandi
|
||||
})
|
||||
}
|
||||
|
||||
/** Return the workspace package root owning one package source file. */
|
||||
function packageRootFor(relativePath: string): string {
|
||||
const match = /^(packages\/[^/]+\/[^/]+)\/src\//.exec(relativePath)
|
||||
if (!match?.[1]) throw new Error(`gen-scoped-events: cannot derive package root from ${relativePath}`)
|
||||
return match[1]
|
||||
}
|
||||
|
||||
/** Quote a generated property key as a single-quoted TypeScript string. */
|
||||
function quote(value: string): string {
|
||||
return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`
|
||||
@@ -419,7 +377,7 @@ export function renderScopedEvents(projectRoot: string = root): string {
|
||||
return new ScopedEventGenerator(new TypeScriptProject(projectRoot)).render()
|
||||
}
|
||||
|
||||
/** Generate or freshness-check the fixed invariants source file. */
|
||||
/** Generate or freshness-check the fixed dsh-scope source file. */
|
||||
function main(): void {
|
||||
const content = renderScopedEvents()
|
||||
const output = resolve(root, OUT)
|
||||
|
||||
@@ -10,6 +10,8 @@ import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { basename, resolve } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import GoalService from '@deepseek-ai/dsh-goal'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
@@ -17,6 +19,7 @@ import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '
|
||||
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 PlanModeService from '@deepseek-ai/dsh-plan-mode'
|
||||
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'
|
||||
@@ -32,12 +35,16 @@ import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
|
||||
import PtyService from '@deepseek-ai/dsh-pty'
|
||||
import * as ToolPty from '@deepseek-ai/dsh-tool-pty'
|
||||
import * as ToolGoal from '@deepseek-ai/dsh-tool-goal'
|
||||
import Lsp from '@deepseek-ai/dsh-lsp'
|
||||
import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp'
|
||||
import * as ToolSkill from '@deepseek-ai/dsh-tool-skill'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
|
||||
import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent'
|
||||
import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
|
||||
import VmWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread'
|
||||
import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph'
|
||||
import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
@@ -81,11 +88,16 @@ class CatalogSearchBashExecutor extends BashExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the descriptor needed to mount schema-producing consumers. */
|
||||
/**
|
||||
* Register the descriptor needed to mount schema-producing consumers. Declares
|
||||
* the full capability set of the shipped in-process providers so consumers
|
||||
* mount under their shipped defaults (tool-subagent's default numeric maxDepth
|
||||
* requires `depthLimit`).
|
||||
*/
|
||||
function registerCatalogSubagentProvider(ctx: Context, name: string): void {
|
||||
const provider: SubagentProvider = {
|
||||
name,
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true },
|
||||
inheritsParentContext: false,
|
||||
start: () => Promise.reject(new Error('tool-catalog provider cannot start a child')),
|
||||
}
|
||||
@@ -163,6 +175,18 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
note:
|
||||
'Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry\'s only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through serialized bindings that re-enter the complete guarded tool pipeline and link each nested execution to this outer result.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-plan-mode',
|
||||
dir: 'plan-mode',
|
||||
source: 'packages/plan/plan-mode/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.systemPrompt', 'ctx.userInteraction (execution time, opportunistic)'],
|
||||
writes: ['tool/call', 'plan/mode inactive on an approved review', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(PlanModeService, { section: 'Tool catalog schema harvest.' })
|
||||
},
|
||||
note:
|
||||
'exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-bash',
|
||||
dir: 'tool-bash',
|
||||
@@ -234,6 +258,49 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
note:
|
||||
'The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-goal',
|
||||
dir: 'tool-goal',
|
||||
source: 'packages/goal/tool-goal/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.agents', 'ctx.goals', 'ctx.systemPrompt', 'a calling Agent in an authorized open turn'],
|
||||
writes: ['tool/call', 'context/message goal snapshot for mutations', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(GoalService)
|
||||
await ctx.plugin(ToolGoal)
|
||||
},
|
||||
note:
|
||||
'create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-lsp',
|
||||
dir: 'tool-lsp',
|
||||
source: 'packages/lsp/tool-lsp/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.lsp', 'ctx.systemPrompt'],
|
||||
writes: ['tool/call', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
// The tool registers from the seam alone; the schema does not depend on any provider.
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(ToolLsp)
|
||||
},
|
||||
note:
|
||||
'The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-ralph',
|
||||
dir: 'tool-ralph',
|
||||
source: 'packages/workflow/tool-ralph/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.workflows', 'ctx.subagents', 'ctx.systemPrompt', 'a calling Agent (exec.agent parents every fresh round)'],
|
||||
writes: ['tool/call', 'tool/result', 'workflow and child session events during execution'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(SubagentService)
|
||||
registerCatalogSubagentProvider(ctx, 'mock')
|
||||
await ctx.plugin(VmWorkflowEngine, { provider: 'mock' })
|
||||
await ctx.plugin(ToolRalph, { subagentProvider: 'mock' })
|
||||
},
|
||||
note:
|
||||
'A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-skill',
|
||||
dir: 'tool-skill',
|
||||
@@ -262,7 +329,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
await ctx.plugin(ToolSubagent, { provider: 'mock' })
|
||||
},
|
||||
note:
|
||||
'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/repl-agent/cordis.yml` and `examples/acp-agent/cordis.yml`.',
|
||||
'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-tasks',
|
||||
@@ -275,7 +342,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
await ctx.plugin(ToolTasks)
|
||||
},
|
||||
note:
|
||||
'The kind-agnostic background-task control surface: a background bash command and a background subagent are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers\' `ctx.tasks.start()`.',
|
||||
'The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers\' `ctx.tasks.start()`.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-todo',
|
||||
|
||||
@@ -1,757 +0,0 @@
|
||||
/**
|
||||
* Generate (and verify) the website API reference under `website/zh-CN/api/`.
|
||||
*
|
||||
* The website's API section is FULLY GENERATED from source — never hand-edit
|
||||
* it. The hand-written hub `api/index.md` sits OUTSIDE the generated subdirs
|
||||
* (`api/cordis/`, `api/harness/`), so the orphan sweep never touches it. Two tiers:
|
||||
*
|
||||
* - `api/cordis/*` — the vendored cordis framework surface (Context, Events,
|
||||
* Fiber, Registry, Service), driven by the CORDIS_PAGES manifest below.
|
||||
* Members come from the real class declarations and the `declare module
|
||||
* './context.ts'` interface merges (the typed `ctx.*` surface a plugin
|
||||
* author actually sees).
|
||||
* - `api/harness/*` — one page per `ctx.<key>` harness service (walked from
|
||||
* every `declare module 'cordis'` Context merge under `packages/<group>/<pkg>/src`),
|
||||
* plus `events.md` listing every harness event grouped by scope.
|
||||
*
|
||||
* Prose comes from the JSDoc; the generator HARD-ERRORS (aggregated) when a
|
||||
* rendered member lacks a summary, a parameter lacks `@param`, or a non-void
|
||||
* annotated return lacks `@returns` — so a vendor sync or a new service method
|
||||
* cannot land undocumented without CI going red. Pages are English (the
|
||||
* planned zh translation flow arrives separately; see docs/i18n/README.md).
|
||||
*
|
||||
* Signature fences use the ` ```ts website-api ` info string and retain the
|
||||
* declaration's original source JSDoc. doc-typecheck only processes its known
|
||||
* info strings, so these bare (non-compilable) fragments are skipped there,
|
||||
* while VitePress still highlights the `ts` token. The sidebar fragment
|
||||
* `website/.vitepress/config/api-sidebar.json` is generated alongside so
|
||||
* navigation can never drift from the page set.
|
||||
*
|
||||
* `tsx scripts/gen-website-api.ts` → write pages + sidebar
|
||||
* `tsx scripts/gen-website-api.ts --check` → exit 1 if committed copies are
|
||||
* stale (doc-sync / CI gate)
|
||||
*/
|
||||
|
||||
import { globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts'
|
||||
import { cordisModuleBody, eventMembers, serviceClasses } from './cordis-walk.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
/** Output roots: generated pages and the generated sidebar fragment. */
|
||||
const PAGES_DIR = 'website/zh-CN/api'
|
||||
const SIDEBAR_OUT = 'website/.vitepress/config/api-sidebar.json'
|
||||
|
||||
/** GitHub blob base for source links on the public site (repo-relative paths
|
||||
* do not resolve on the built site, unlike the in-repo catalogs). */
|
||||
const GITHUB = 'https://github.com/deepseek-harness/deepseek-harness/blob/master'
|
||||
|
||||
/** Signature-fence info string (skipped by doc-typecheck, highlighted as ts). */
|
||||
const FENCE = 'ts website-api'
|
||||
|
||||
/** Return sorted repository-relative glob matches with stable URL separators. */
|
||||
function repoGlob(pattern: string): string[] {
|
||||
return globSync(pattern, { cwd: root }).map(rel => rel.replaceAll('\\', '/')).sort()
|
||||
}
|
||||
|
||||
/** One rendered member: a method/property plus its parsed JSDoc. */
|
||||
interface MemberDoc {
|
||||
/** Display name, e.g. `on` or `agent/pre-step`. */
|
||||
name: string
|
||||
/** Heading suffix with parameter names, e.g. `(name, listener, options?)`;
|
||||
* empty for properties. */
|
||||
heading: string
|
||||
/** All overload signature lines (bodies stripped). */
|
||||
signatures: string[]
|
||||
/** Original source JSDoc, dedented only from its containing declaration. */
|
||||
jsDoc: string
|
||||
/** Description prose, one paragraph per line. */
|
||||
doc: string
|
||||
/** Parameter name → `@param` text, in declaration order. */
|
||||
params: { name: string; text: string }[]
|
||||
/** `@returns` text, or null for void/undocumented. */
|
||||
returns: string | null
|
||||
/** Repo-relative `file:line` of the (first) declaration. */
|
||||
source: string
|
||||
}
|
||||
|
||||
/** A cordis-page section: which declarations it renders. */
|
||||
type Section =
|
||||
| { kind: 'class'; file: string; symbol: string; prefix?: string; heading?: string }
|
||||
| { kind: 'context-merge'; file: string; heading?: string }
|
||||
| { kind: 'decl'; file: string; symbol: string }
|
||||
|
||||
/** One generated cordis page. */
|
||||
interface CordisPage {
|
||||
out: string
|
||||
title: string
|
||||
intro: string
|
||||
sections: Section[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The cordis tier manifest. Deliberately explicit (not a blind walk): the
|
||||
* vendor `Context` mixes true plugin-author surface with internals, and page
|
||||
* grouping is an editorial choice — but every member listed here is still
|
||||
* EXTRACTED, never transcribed, so signatures and docs cannot drift.
|
||||
*/
|
||||
const CORDIS_PAGES: CordisPage[] = [
|
||||
{
|
||||
out: 'cordis/context.md',
|
||||
title: 'Context',
|
||||
intro: 'The context is the core cordis object: every service, event, and lifecycle API is reached through `ctx`. Event methods (`ctx.on`, `ctx.emit`, …) are documented on [Events](./events.md); `ctx.effect` and `ctx.fiber` on [Fiber](./fiber.md); `ctx.plugin` and `ctx.inject` on [Registry](./registry.md).',
|
||||
sections: [
|
||||
{ kind: 'class', file: 'vendor/cordis/src/context.ts', symbol: 'Context', prefix: 'ctx.' },
|
||||
{ kind: 'context-merge', file: 'vendor/cordis/src/reflect.ts', heading: 'Service store and mixins' },
|
||||
],
|
||||
},
|
||||
{
|
||||
out: 'cordis/events.md',
|
||||
title: 'Events',
|
||||
intro: 'The event system mixed into every context. Harness-defined events are cataloged on [Harness events](../harness/events.md).',
|
||||
sections: [
|
||||
{ kind: 'context-merge', file: 'vendor/cordis/src/events.ts' },
|
||||
{ kind: 'decl', file: 'vendor/cordis/src/events.ts', symbol: 'EventOptions' },
|
||||
{ kind: 'decl', file: 'vendor/cordis/src/events.ts', symbol: 'DispatchMode' },
|
||||
],
|
||||
},
|
||||
{
|
||||
out: 'cordis/fiber.md',
|
||||
title: 'Fiber',
|
||||
intro: 'A fiber is one loaded plugin instance: its lifecycle state, validated config, and registered effects. `ctx.fiber` is the current fiber; `ctx.effect()` delegates to it.',
|
||||
sections: [
|
||||
{ kind: 'context-merge', file: 'vendor/cordis/src/fiber.ts' },
|
||||
{ kind: 'class', file: 'vendor/cordis/src/fiber.ts', symbol: 'Fiber', heading: 'The Fiber class' },
|
||||
{ kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'Effect' },
|
||||
{ kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'Disposable' },
|
||||
{ kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'EffectMeta' },
|
||||
{ kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'CordisError' },
|
||||
{ kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'ValidationError' },
|
||||
],
|
||||
},
|
||||
{
|
||||
out: 'cordis/registry.md',
|
||||
title: 'Registry',
|
||||
intro: 'Plugin loading and dependency injection.',
|
||||
sections: [
|
||||
{ kind: 'context-merge', file: 'vendor/cordis/src/registry.ts' },
|
||||
{ kind: 'decl', file: 'vendor/cordis/src/registry.ts', symbol: 'Plugin' },
|
||||
{ kind: 'decl', file: 'vendor/cordis/src/registry.ts', symbol: 'Inject' },
|
||||
],
|
||||
},
|
||||
{
|
||||
out: 'cordis/service.md',
|
||||
title: 'Service',
|
||||
intro: 'Base class for context services: subclass it and load the subclass as a plugin to register `ctx.<name>`.',
|
||||
sections: [
|
||||
{ kind: 'class', file: 'vendor/cordis/src/service.ts', symbol: 'Service' },
|
||||
],
|
||||
},
|
||||
]
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const sfCache = new Map<string, { sf: ts.SourceFile; text: string }>()
|
||||
|
||||
/** Parse (and cache) one repo-relative source file. */
|
||||
function load(rel: string): { sf: ts.SourceFile; text: string } {
|
||||
const cached = sfCache.get(rel)
|
||||
if (cached) return cached
|
||||
const text = readFileSync(resolve(root, rel), 'utf8')
|
||||
const sf = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true)
|
||||
const entry = { sf, text }
|
||||
sfCache.set(rel, entry)
|
||||
return entry
|
||||
}
|
||||
// The module-merge walk (cordisModuleBody / eventMembers / serviceClasses) is
|
||||
// shared with gen-cordis-catalog.ts via cordis-walk.ts.
|
||||
|
||||
/** Original JSDoc with only the source container's indentation removed. */
|
||||
function sourceJSDoc(text: string, sf: ts.SourceFile, node: ts.Node): string {
|
||||
const raw = rawJsDoc(text, node)
|
||||
if (raw === '') return ''
|
||||
const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf))
|
||||
const lineStart = sf.getPositionOfLineAndCharacter(line, 0)
|
||||
const indent = text.slice(lineStart, node.getStart(sf))
|
||||
return raw.split('\n')
|
||||
.map((sourceLine, index) => index > 0 && sourceLine.startsWith(indent)
|
||||
? sourceLine.slice(indent.length)
|
||||
: sourceLine)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
/** Signature text of a member: full text minus body/initializer, whitespace
|
||||
* collapsed, trailing semicolon stripped. */
|
||||
function signatureOf(member: ts.Node, sf: ts.SourceFile): string {
|
||||
const full = member.getText(sf)
|
||||
const tail = (member as { body?: ts.Node; initializer?: ts.Node }).body
|
||||
?? (member as { initializer?: ts.Node }).initializer
|
||||
const sig = tail ? full.slice(0, full.length - tail.getText(sf).length).replace(/[=\s]+$/, '') : full
|
||||
return sig.replace(/\s*;?\s*$/, '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** `(a, b?, ...rest)` heading suffix from a parameter list, `this` dropped. */
|
||||
function headingParams(parameters: readonly ts.ParameterDeclaration[], sf: ts.SourceFile): string {
|
||||
const names = parameters
|
||||
.filter(p => !(ts.isIdentifier(p.name) && p.name.text === 'this'))
|
||||
.map((p) => {
|
||||
const dots = p.dotDotDotToken ? '...' : ''
|
||||
const opt = p.questionToken || p.initializer ? '?' : ''
|
||||
return `${dots}${p.name.getText(sf)}${opt}`
|
||||
})
|
||||
return `(${names.join(', ')})`
|
||||
}
|
||||
|
||||
/** Whether a class member is renderable public API (non-static half). */
|
||||
function isPublicInstance(member: ts.ClassElement): boolean {
|
||||
const mods = ts.getCombinedModifierFlags(member)
|
||||
if (mods & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected | ts.ModifierFlags.Static)) return false
|
||||
if (!member.name) return false
|
||||
if (ts.isComputedPropertyName(member.name) || ts.isPrivateIdentifier(member.name)) return false
|
||||
return !member.name.getText().startsWith('_')
|
||||
}
|
||||
|
||||
/** Whether a class member is renderable public STATIC API. */
|
||||
function isPublicStatic(member: ts.ClassElement): boolean {
|
||||
const mods = ts.getCombinedModifierFlags(member)
|
||||
if (mods & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected)) return false
|
||||
if (!(mods & ts.ModifierFlags.Static)) return false
|
||||
if (!member.name || ts.isComputedPropertyName(member.name) || ts.isPrivateIdentifier(member.name)) return false
|
||||
return !member.name.getText().startsWith('_')
|
||||
}
|
||||
|
||||
/** Build a MemberDoc from a declaration group (overloads share one entry),
|
||||
* collecting completeness violations for everything rendered. */
|
||||
function memberDoc(
|
||||
where: string,
|
||||
name: string,
|
||||
group: (ts.MethodDeclaration | ts.MethodSignature | ts.PropertyDeclaration | ts.PropertySignature | ts.GetAccessorDeclaration)[],
|
||||
rel: string,
|
||||
violations: string[],
|
||||
): MemberDoc {
|
||||
const { sf, text } = load(rel)
|
||||
const first = group[0]
|
||||
if (!first) throw new Error(`gen-website-api: empty member group for ${name}`)
|
||||
// Doc from the first overload that carries JSDoc prose.
|
||||
const rawDocs = group.map(m => sourceJSDoc(text, sf, m))
|
||||
const docIndex = rawDocs.findIndex(r => parseJsDoc(r).doc !== '')
|
||||
const raw = docIndex === -1 ? '' : (rawDocs[docIndex] ?? '')
|
||||
const doc = parseJsDoc(raw).doc
|
||||
if (!doc) violations.push(`${where} has no JSDoc prose.`)
|
||||
const { params: tags, returns } = parseTags(raw)
|
||||
const params: { name: string; text: string }[] = []
|
||||
let returnsText: string | null = null
|
||||
const funcLike = group.filter((m): m is ts.MethodDeclaration | ts.MethodSignature => ts.isMethodDeclaration(m) || ts.isMethodSignature(m))
|
||||
const docCarrier = funcLike[docIndex === -1 ? 0 : docIndex]
|
||||
if (docCarrier) {
|
||||
checkParams(where, 'website-api', docCarrier.parameters, tags, sf,
|
||||
p => ts.isIdentifier(p.name) && p.name.text === 'this', violations)
|
||||
if (docCarrier.type) {
|
||||
checkReturns(where, docCarrier.type, returns, sf, violations)
|
||||
} else if (!returns && ts.isMethodDeclaration(docCarrier)) {
|
||||
// Comment-only vendor policy: we cannot add a return type annotation to
|
||||
// pinned upstream source, so an unannotated rendered method must carry
|
||||
// an explicit @returns describing the result instead.
|
||||
violations.push(`${where} has no return type annotation; document the result with @returns.`)
|
||||
}
|
||||
for (const p of docCarrier.parameters) {
|
||||
if (ts.isIdentifier(p.name) && p.name.text === 'this') continue
|
||||
const pname = p.name.getText(sf)
|
||||
const tag = tags.get(pname)
|
||||
if (tag) params.push({ name: pname, text: tag })
|
||||
}
|
||||
returnsText = returns
|
||||
}
|
||||
const headingSource = docCarrier ?? funcLike[0]
|
||||
return {
|
||||
name,
|
||||
heading: headingSource ? headingParams(headingSource.parameters, sf) : '',
|
||||
signatures: (ts.isMethodDeclaration(first) && funcLike.length > 1
|
||||
? funcLike.filter(m => ts.isMethodDeclaration(m) && !m.body)
|
||||
: group).map(m => signatureOf(m, sf)),
|
||||
jsDoc: raw,
|
||||
doc,
|
||||
params,
|
||||
returns: returnsText,
|
||||
source: pointer(rel, sf, first),
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve an `extends Pick<Class, 'a' | 'b'>` heritage clause on the Context
|
||||
* merge to the named members of `Class` declared in the same file — the fiber
|
||||
* merge (`interface Context extends Pick<Fiber, 'effect'>`) is the motivating
|
||||
* case: without this, `ctx.effect` had no documented signature anywhere. */
|
||||
function heritageMembers(
|
||||
stmt: ts.InterfaceDeclaration,
|
||||
sf: ts.SourceFile,
|
||||
groups: Map<string, (ts.MethodSignature | ts.PropertySignature | ts.MethodDeclaration)[]>,
|
||||
): void {
|
||||
for (const clause of stmt.heritageClauses ?? []) {
|
||||
for (const type of clause.types) {
|
||||
if (!ts.isIdentifier(type.expression) || type.expression.text !== 'Pick') continue
|
||||
const [target, keys] = type.typeArguments ?? []
|
||||
if (!target || !keys || !ts.isTypeReferenceNode(target)) continue
|
||||
const targetName = target.typeName.getText(sf)
|
||||
const cls = sf.statements.find(
|
||||
(s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === targetName,
|
||||
)
|
||||
if (!cls) continue
|
||||
const picked = new Set<string>()
|
||||
const collect = (node: ts.TypeNode): void => {
|
||||
if (ts.isLiteralTypeNode(node) && ts.isStringLiteral(node.literal)) picked.add(node.literal.text)
|
||||
if (ts.isUnionTypeNode(node)) node.types.forEach(collect)
|
||||
}
|
||||
collect(keys)
|
||||
for (const member of cls.members) {
|
||||
if (!ts.isMethodDeclaration(member)) continue
|
||||
const name = member.name.getText(sf)
|
||||
if (!picked.has(name)) continue
|
||||
const group = groups.get(name) ?? []
|
||||
group.push(member)
|
||||
groups.set(name, group)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Members of the `interface Context` merge in `rel`, overloads grouped;
|
||||
* `Pick<…>` heritage resolved to the picked class members. */
|
||||
function contextMergeMembers(rel: string, violations: string[]): MemberDoc[] {
|
||||
const { sf } = load(rel)
|
||||
const body = cordisModuleBody(sf)
|
||||
if (!body) throw new Error(`gen-website-api: ${rel} has no context module merge`)
|
||||
const groups = new Map<string, (ts.MethodSignature | ts.PropertySignature | ts.MethodDeclaration)[]>()
|
||||
for (const stmt of body.statements) {
|
||||
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Context') continue
|
||||
heritageMembers(stmt, sf, groups)
|
||||
for (const member of stmt.members) {
|
||||
if (!ts.isMethodSignature(member) && !ts.isPropertySignature(member)) continue
|
||||
if (ts.isComputedPropertyName(member.name)) continue
|
||||
const name = member.name.getText(sf)
|
||||
const group = groups.get(name) ?? []
|
||||
group.push(member)
|
||||
groups.set(name, group)
|
||||
}
|
||||
}
|
||||
return [...groups.entries()].map(([name, group]) =>
|
||||
memberDoc(`ctx.${name} (${rel})`, name, group, rel, violations))
|
||||
}
|
||||
|
||||
/** Instance + static members of one class, as two rendered lists. The class's
|
||||
* same-named top-level interface half (declaration merging — vendor Context
|
||||
* declares `root`/`events`/`logger`/… on the interface) is folded into the
|
||||
* instance list, so neither half of a merged symbol goes undocumented. */
|
||||
function classMembers(rel: string, className: string, violations: string[]): {
|
||||
doc: string
|
||||
instance: MemberDoc[]
|
||||
statics: MemberDoc[]
|
||||
source: string
|
||||
} {
|
||||
const { sf, text } = load(rel)
|
||||
const cls = sf.statements.find(
|
||||
(s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === className,
|
||||
)
|
||||
if (!cls) throw new Error(`gen-website-api: class ${className} not found in ${rel}`)
|
||||
const clsDoc = parseJsDoc(rawJsDoc(text, cls)).doc
|
||||
if (!clsDoc) violations.push(`class ${className} (${pointer(rel, sf, cls)}) has no JSDoc.`)
|
||||
type Renderable = ts.MethodDeclaration | ts.PropertyDeclaration | ts.GetAccessorDeclaration | ts.PropertySignature
|
||||
const instance = new Map<string, Renderable[]>()
|
||||
const statics = new Map<string, (ts.MethodDeclaration | ts.PropertyDeclaration)[]>()
|
||||
for (const member of cls.members) {
|
||||
const renderable = ts.isMethodDeclaration(member) || ts.isPropertyDeclaration(member) || ts.isGetAccessorDeclaration(member)
|
||||
if (!renderable) continue
|
||||
const name = member.name.getText(sf)
|
||||
if (isPublicInstance(member)) {
|
||||
const group = instance.get(name) ?? []
|
||||
group.push(member)
|
||||
instance.set(name, group)
|
||||
} else if (isPublicStatic(member) && !ts.isGetAccessorDeclaration(member)) {
|
||||
const group = statics.get(name) ?? []
|
||||
group.push(member)
|
||||
statics.set(name, group)
|
||||
}
|
||||
}
|
||||
const iface = sf.statements.find(
|
||||
(s): s is ts.InterfaceDeclaration => ts.isInterfaceDeclaration(s) && s.name.text === className,
|
||||
)
|
||||
for (const member of iface?.members ?? []) {
|
||||
if (!ts.isPropertySignature(member)) continue
|
||||
if (ts.isComputedPropertyName(member.name)) continue
|
||||
const name = member.name.getText(sf)
|
||||
const group = instance.get(name) ?? []
|
||||
group.push(member)
|
||||
instance.set(name, group)
|
||||
}
|
||||
const toDocs = (groups: Map<string, Renderable[]>, prefix: string): MemberDoc[] =>
|
||||
[...groups.entries()].map(([name, group]) =>
|
||||
memberDoc(`${prefix}${name} (${rel})`, name, group, rel, violations))
|
||||
return {
|
||||
doc: clsDoc,
|
||||
instance: toDocs(instance, `${className}#`),
|
||||
statics: toDocs(statics, `${className}.`),
|
||||
source: pointer(rel, sf, cls),
|
||||
}
|
||||
}
|
||||
|
||||
/** Splice every function-like BODY out of a declaration's text, leaving the
|
||||
* signature (`) {` → `)`). A reference paste shows shapes, not implementation;
|
||||
* property initializers (e.g. an `as const` code table) are data and stay. */
|
||||
function stripBodies(node: ts.Node, sf: ts.SourceFile): string {
|
||||
const cuts: { start: number; end: number }[] = []
|
||||
const visit = (n: ts.Node): void => {
|
||||
const funcLike = ts.isMethodDeclaration(n) || ts.isConstructorDeclaration(n)
|
||||
|| ts.isFunctionDeclaration(n) || ts.isGetAccessorDeclaration(n) || ts.isSetAccessorDeclaration(n)
|
||||
if (funcLike && n.body) {
|
||||
// Cut from just after the parameter close (or return-type end) through
|
||||
// the body, so `foo(a: string) { … }` renders as `foo(a: string)`.
|
||||
const sigEnd = (n.type ?? n.parameters[n.parameters.length - 1] ?? n).getEnd()
|
||||
// Find the `)` (and optional `: Type`) boundary: body start is exact.
|
||||
cuts.push({ start: sigEnd, end: n.body.getEnd() })
|
||||
return // nothing renderable inside the body
|
||||
}
|
||||
n.forEachChild(visit)
|
||||
}
|
||||
visit(node)
|
||||
const base = node.getStart(sf)
|
||||
let out = node.getText(sf)
|
||||
for (const cut of cuts.sort((a, b) => b.start - a.start)) {
|
||||
const head = out.slice(0, cut.start - base)
|
||||
// Keep everything of the signature up to the closing paren / return type,
|
||||
// drop ` { … }`. The head may end mid-signature (last param), so retain
|
||||
// the source between sigEnd and the body's `{` MINUS trailing space.
|
||||
const between = out.slice(cut.start - base, cut.end - base)
|
||||
const bodyBrace = between.indexOf('{')
|
||||
out = head + between.slice(0, bodyBrace).trimEnd() + out.slice(cut.end - base)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Verbatim declaration paste: every top-level statement named `symbol`
|
||||
* (class + merged namespace both), with leading JSDoc prose extracted and
|
||||
* function bodies stripped (a reference shows shapes, not implementation). */
|
||||
function declPaste(rel: string, symbol: string): { doc: string; code: string; source: string } {
|
||||
const { sf, text } = load(rel)
|
||||
const matches = sf.statements.filter((s) => {
|
||||
const named = ts.isInterfaceDeclaration(s) || ts.isTypeAliasDeclaration(s)
|
||||
|| ts.isClassDeclaration(s) || ts.isEnumDeclaration(s) || ts.isModuleDeclaration(s)
|
||||
return named && s.name?.getText(sf) === symbol
|
||||
})
|
||||
if (matches.length === 0) throw new Error(`gen-website-api: declaration ${symbol} not found in ${rel}`)
|
||||
const first = matches[0]
|
||||
if (!first) throw new Error(`gen-website-api: declaration ${symbol} not found in ${rel}`)
|
||||
const firstJSDoc = sourceJSDoc(text, sf, first)
|
||||
const doc = parseJsDoc(firstJSDoc).doc
|
||||
const code = matches.map((statement) => {
|
||||
const jsDoc = sourceJSDoc(text, sf, statement)
|
||||
const declaration = stripBodies(statement, sf).replace(/^export\s+(default\s+)?/, '')
|
||||
return jsDoc === '' ? declaration : `${jsDoc}\n${declaration}`
|
||||
}).join('\n\n')
|
||||
return { doc, code, source: pointer(rel, sf, first) }
|
||||
}
|
||||
|
||||
/** One harness service with member-level detail. */
|
||||
interface HarnessService {
|
||||
key: string
|
||||
type: string
|
||||
abstract: boolean
|
||||
doc: string
|
||||
members: MemberDoc[]
|
||||
source: string
|
||||
/** Owning npm package name (from the package.json beside the entry). */
|
||||
pkg: string
|
||||
}
|
||||
|
||||
/** Walk every harness `declare module 'cordis'` Context merge → services. */
|
||||
function collectHarnessServices(violations: string[]): HarnessService[] {
|
||||
const services: HarnessService[] = []
|
||||
for (const rel of repoGlob('packages/*/*/src/index.ts')) {
|
||||
const { sf, text } = load(rel)
|
||||
if (!text.includes('interface Context')) continue
|
||||
const body = cordisModuleBody(sf)
|
||||
if (!body) continue
|
||||
const pkgJson = resolve(root, dirname(dirname(rel)), 'package.json')
|
||||
// Manifest shape is repo-owned; `name` is the one field read here.
|
||||
const manifest = JSON.parse(readFileSync(pkgJson, 'utf8')) as { name: string }
|
||||
const pkg = manifest.name
|
||||
for (const { key, type, cls, abstract, doc: clsDoc } of serviceClasses(body, sf, rel, violations)) {
|
||||
const groups = new Map<string, (ts.MethodDeclaration | ts.PropertyDeclaration | ts.GetAccessorDeclaration)[]>()
|
||||
for (const member of cls.members) {
|
||||
// Public properties are API too: ctx.codeRuntime.language/isolation
|
||||
// are readonly descriptors consumers key presentation off.
|
||||
const renderable = ts.isMethodDeclaration(member) || ts.isPropertyDeclaration(member) || ts.isGetAccessorDeclaration(member)
|
||||
if (!renderable) continue
|
||||
if (!isPublicInstance(member)) continue
|
||||
const name = member.name.getText(sf)
|
||||
const group = groups.get(name) ?? []
|
||||
group.push(member)
|
||||
groups.set(name, group)
|
||||
}
|
||||
const members = [...groups.entries()].map(([name, group]) =>
|
||||
memberDoc(`ctx.${key}.${name} (${rel})`, name, group, rel, violations))
|
||||
services.push({ key, type, abstract, doc: clsDoc, members, source: pointer(rel, sf, cls), pkg })
|
||||
}
|
||||
}
|
||||
return services.sort((a, b) => a.key.localeCompare(b.key))
|
||||
}
|
||||
|
||||
/** One harness event with member-level detail. */
|
||||
interface HarnessEvent {
|
||||
name: string
|
||||
scope: string
|
||||
mode: Mode | null
|
||||
signature: string
|
||||
/** Original source event JSDoc, dedented from its module/interface. */
|
||||
jsDoc: string
|
||||
doc: string
|
||||
params: { name: string; text: string }[]
|
||||
source: string
|
||||
}
|
||||
|
||||
/** Walk every harness `interface Events` merge → events. */
|
||||
function collectHarnessEvents(violations: string[]): HarnessEvent[] {
|
||||
const events: HarnessEvent[] = []
|
||||
for (const rel of repoGlob('packages/*/*/src/*.ts')) {
|
||||
const { sf, text } = load(rel)
|
||||
if (!text.includes('interface Events')) continue
|
||||
const body = cordisModuleBody(sf)
|
||||
if (!body) continue
|
||||
for (const { name, member } of eventMembers(body, sf)) {
|
||||
const raw = sourceJSDoc(text, sf, member)
|
||||
const { doc, mode } = parseJsDoc(raw)
|
||||
if (!mode) violations.push(`event '${name}' (${pointer(rel, sf, member)}) is missing @mode.`)
|
||||
if (!doc) violations.push(`event '${name}' (${pointer(rel, sf, member)}) has no JSDoc prose.`)
|
||||
const { params: tags } = parseTags(raw)
|
||||
const last = member.parameters.at(-1)
|
||||
const hasNext = !!last && last.name.getText(sf) === 'next'
|
||||
checkParams(`event '${name}' (${pointer(rel, sf, member)})`, 'website-api', member.parameters, tags, sf,
|
||||
p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations)
|
||||
const params: { name: string; text: string }[] = []
|
||||
for (const p of member.parameters) {
|
||||
const pname = p.name.getText(sf)
|
||||
const tag = tags.get(pname)
|
||||
if (tag) params.push({ name: pname, text: tag })
|
||||
}
|
||||
events.push({ name, scope: name.split('/')[0] ?? name, mode, signature: signatureOf(member, sf), jsDoc: raw, doc, params, source: pointer(rel, sf, member) })
|
||||
}
|
||||
}
|
||||
return events.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BANNER = '<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->'
|
||||
|
||||
/** GitHub source link for a `file:line` pointer. */
|
||||
function sourceLink(source: string): string {
|
||||
const [file, line] = source.split(':')
|
||||
return `[Source](${GITHUB}/${file}#L${line})`
|
||||
}
|
||||
|
||||
/** Normalize JSDoc inline `{@link X}` / `{@link X|label}` / `{@link X label}`
|
||||
* tags to plain Markdown code spans — left verbatim they leak into the built
|
||||
* page as literal `{@link …}` text. */
|
||||
function unlink(text: string): string {
|
||||
return text.replace(/\{@link\s+([^}|\s]+)\s*(?:[|\s]\s*([^}]*))?\}/g, (_m, target: string, label?: string) => {
|
||||
const name = label?.trim()
|
||||
return name && name !== '' ? name : `\`${target}\``
|
||||
})
|
||||
}
|
||||
|
||||
/** Render prose paragraphs (one per line of `doc`), JSDoc links normalized. */
|
||||
function prose(doc: string): string[] {
|
||||
return unlink(doc).split('\n').filter(l => l.trim() !== '')
|
||||
}
|
||||
|
||||
/** Render one member section at heading depth 3. */
|
||||
function renderMember(prefix: string, m: MemberDoc): string[] {
|
||||
const lines: string[] = []
|
||||
const call = m.heading === '' ? '' : m.heading
|
||||
lines.push(`### ${prefix}${m.name}${call}`, '')
|
||||
lines.push('```' + FENCE)
|
||||
lines.push(m.jsDoc)
|
||||
for (const sig of m.signatures) lines.push(sig)
|
||||
lines.push('```', '')
|
||||
lines.push(...prose(m.doc), '')
|
||||
if (m.params.length > 0) {
|
||||
for (const p of m.params) lines.push(`- \`${p.name}\` — ${unlink(p.text)}`)
|
||||
lines.push('')
|
||||
}
|
||||
if (m.returns) lines.push(`**Returns** ${unlink(m.returns)}`, '')
|
||||
lines.push(sourceLink(m.source), '')
|
||||
return lines
|
||||
}
|
||||
|
||||
/** Render one cordis-tier page from its manifest entry. */
|
||||
function renderCordisPage(page: CordisPage, violations: string[]): string {
|
||||
const lines: string[] = [BANNER, '', `# ${page.title}`, '', page.intro, '']
|
||||
for (const section of page.sections) {
|
||||
if (section.kind !== 'decl' && section.heading) lines.push(`## ${section.heading}`, '')
|
||||
if (section.kind === 'context-merge') {
|
||||
for (const m of contextMergeMembers(section.file, violations)) {
|
||||
lines.push(...renderMember('ctx.', m))
|
||||
}
|
||||
} else if (section.kind === 'class') {
|
||||
const cls = classMembers(section.file, section.symbol, violations)
|
||||
lines.push(...prose(cls.doc), '', sourceLink(cls.source), '')
|
||||
const instancePrefix = section.prefix ?? `${section.symbol.toLowerCase()}.`
|
||||
for (const m of cls.instance) lines.push(...renderMember(instancePrefix, m))
|
||||
if (cls.statics.length > 0) {
|
||||
lines.push('## Static members', '')
|
||||
for (const m of cls.statics) lines.push(...renderMember(`${section.symbol}.`, m))
|
||||
}
|
||||
} else {
|
||||
const decl = declPaste(section.file, section.symbol)
|
||||
lines.push(`## ${section.symbol}`, '')
|
||||
if (decl.doc) lines.push(...prose(decl.doc), '')
|
||||
lines.push('```' + FENCE, decl.code, '```', '', sourceLink(decl.source), '')
|
||||
}
|
||||
}
|
||||
return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`
|
||||
}
|
||||
|
||||
/** kebab-case a ctx key: `agentLoop` → `agent-loop`. */
|
||||
function kebab(key: string): string {
|
||||
return key.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)
|
||||
}
|
||||
|
||||
/** Render one harness service page. */
|
||||
function renderServicePage(svc: HarnessService): string {
|
||||
const seam = svc.abstract ? ' (abstract seam)' : ''
|
||||
const lines: string[] = [
|
||||
BANNER, '',
|
||||
`# ctx.${svc.key}`, '',
|
||||
`\`${svc.type}\`${seam} — provided by \`${svc.pkg}\`.`, '',
|
||||
...prose(svc.doc), '',
|
||||
sourceLink(svc.source), '',
|
||||
]
|
||||
for (const m of svc.members) lines.push(...renderMember(`ctx.${svc.key}.`, m))
|
||||
return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`
|
||||
}
|
||||
|
||||
/** Render the harness events page, grouped by scope. */
|
||||
function renderEventsPage(events: HarnessEvent[]): string {
|
||||
const lines: string[] = [
|
||||
BANNER, '',
|
||||
'# Harness events', '',
|
||||
`Every event the harness packages declare on the cordis event bus (${events.length} total), grouped by scope. The **mode** is the dispatch semantics (\`emit\` fire-and-forget, \`parallel\` awaited, \`serial\` first-bail, \`waterfall\` veto-chain — a waterfall listener MUST call \`next()\` to delegate).`, '',
|
||||
]
|
||||
const scopes = [...new Set(events.map(e => e.scope))].sort()
|
||||
for (const scope of scopes) {
|
||||
lines.push(`## ${scope}/*`, '')
|
||||
for (const e of events.filter(ev => ev.scope === scope)) {
|
||||
lines.push(`### ${e.name}`, '')
|
||||
lines.push(`**Mode:** \`${e.mode ?? 'unknown'}\``, '')
|
||||
lines.push('```' + FENCE, e.jsDoc, e.signature, '```', '')
|
||||
lines.push(...prose(e.doc), '')
|
||||
if (e.params.length > 0) {
|
||||
for (const p of e.params) lines.push(`- \`${p.name}\` — ${unlink(p.text)}`)
|
||||
lines.push('')
|
||||
}
|
||||
lines.push(sourceLink(e.source), '')
|
||||
}
|
||||
}
|
||||
return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assembly + CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Build every generated file as `relPath → content`. */
|
||||
export function generate(): Map<string, string> {
|
||||
const violations: string[] = []
|
||||
const files = new Map<string, string>()
|
||||
|
||||
for (const page of CORDIS_PAGES) {
|
||||
files.set(`${PAGES_DIR}/${page.out}`, renderCordisPage(page, violations))
|
||||
}
|
||||
|
||||
const services = collectHarnessServices(violations)
|
||||
for (const svc of services) {
|
||||
files.set(`${PAGES_DIR}/harness/${kebab(svc.key)}.md`, renderServicePage(svc))
|
||||
}
|
||||
|
||||
const events = collectHarnessEvents(violations)
|
||||
files.set(`${PAGES_DIR}/harness/events.md`, renderEventsPage(events))
|
||||
|
||||
for (const [rel, content] of files) {
|
||||
if (!rel.endsWith('.md')) continue
|
||||
for (const match of content.matchAll(/^```ts website-api\n([\s\S]*?)\n```$/gm)) {
|
||||
const body = match[1] ?? ''
|
||||
if (!body.startsWith('/**')) {
|
||||
violations.push(`${rel}: a ts website-api fence does not begin with original source JSDoc.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reportViolations('gen-website-api', violations)
|
||||
|
||||
const sidebar = {
|
||||
cordis: CORDIS_PAGES.map(p => ({
|
||||
text: p.title,
|
||||
link: `/zh-CN/api/${p.out.replace(/\.md$/, '')}`,
|
||||
})),
|
||||
harness: [
|
||||
...services.map(s => ({ text: `ctx.${s.key}`, link: `/zh-CN/api/harness/${kebab(s.key)}` })),
|
||||
{ text: 'Events', link: '/zh-CN/api/harness/events' },
|
||||
],
|
||||
}
|
||||
files.set(SIDEBAR_OUT, `${JSON.stringify(sidebar, null, 2)}\n`)
|
||||
return files
|
||||
}
|
||||
|
||||
/** CLI entry: default writes, `--check` fails on stale/orphan files. Guarded
|
||||
* behind an entry-point check so tests can import `generate()`. */
|
||||
function main(): void {
|
||||
const check = process.argv.includes('--check')
|
||||
const files = generate()
|
||||
|
||||
// Orphan detection: a generated-dir page that generate() no longer emits
|
||||
// (e.g. a service was renamed) must be deleted, not left to rot.
|
||||
const expected = new Set([...files.keys()])
|
||||
// Orphans live in the generated subdirs only; the hand-written api/index.md
|
||||
// is one level up and never matches this glob.
|
||||
const onDisk = repoGlob(`${PAGES_DIR}/{cordis,harness}/*.md`)
|
||||
const orphans = onDisk.filter(rel => !expected.has(rel))
|
||||
|
||||
if (check) {
|
||||
const stale: string[] = []
|
||||
for (const [rel, content] of files) {
|
||||
let current: string | null = null
|
||||
try {
|
||||
current = readFileSync(resolve(root, rel), 'utf8')
|
||||
} catch {
|
||||
// Missing file: reported as stale below; readFileSync is the probe.
|
||||
}
|
||||
if (current !== content) stale.push(rel)
|
||||
}
|
||||
if (stale.length > 0 || orphans.length > 0) {
|
||||
console.error('gen-website-api: website API reference is stale. Run `pnpm run gen-website-api` and commit the result.')
|
||||
for (const rel of stale) console.error(` stale: ${rel}`)
|
||||
for (const rel of orphans) console.error(` orphan (delete): ${rel}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`gen-website-api: ${files.size} generated file(s) fresh.`)
|
||||
return
|
||||
}
|
||||
|
||||
for (const [rel, content] of files) {
|
||||
const abs = resolve(root, rel)
|
||||
mkdirSync(dirname(abs), { recursive: true })
|
||||
writeFileSync(abs, content)
|
||||
}
|
||||
for (const rel of orphans) {
|
||||
console.log(`gen-website-api: orphan page ${rel} — delete it (no longer generated).`)
|
||||
}
|
||||
console.log(`gen-website-api: wrote ${files.size} file(s).`)
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Shared fenced-code-block extractor for the Markdown doc gates
|
||||
* (`doc-typecheck.ts`, `verify-website-yaml.ts`). One scanner, per-gate
|
||||
* (currently `doc-typecheck.ts`; future Markdown gates can share it). One scanner, per-gate
|
||||
* classification: each gate maps a fence info string (` ```ts `,
|
||||
* ` ```yaml ignore-check `, …) to its own kind tag and receives every
|
||||
* classified block with its 1-based opening-fence line.
|
||||
|
||||
187
scripts/package-invariants.spec.ts
Normal file
187
scripts/package-invariants.spec.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
collectPackageInvariantViolations,
|
||||
} from './package-invariants.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function handwrittenInvariant(packageName: string): string {
|
||||
return `
|
||||
export const name = 'probe-invariant'
|
||||
export const inject = ['invariants']
|
||||
const install = (ctx: { on(name: string, listener: (value: number) => void): void }, fail: (message: string) => never) => {
|
||||
ctx.on('probe/value', (value) => {
|
||||
if (value < 0) fail('observed values must be non-negative')
|
||||
})
|
||||
}
|
||||
export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) =>
|
||||
Promise.resolve(ctx.invariants.register(${JSON.stringify(packageName)}, install))
|
||||
`
|
||||
}
|
||||
|
||||
function fixture(options: {
|
||||
packageName?: string
|
||||
source?: string
|
||||
invariantExport?: boolean
|
||||
invariantDependency?: boolean
|
||||
invariantReference?: boolean
|
||||
buildEntry?: boolean
|
||||
} = {}): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-package-invariants-'))
|
||||
roots.push(root)
|
||||
const dir = join(root, 'packages/core/probe')
|
||||
mkdirSync(join(dir, 'src'), { recursive: true })
|
||||
const packageName = options.packageName ?? '@deepseek-ai/dsh-probe'
|
||||
const manifest = {
|
||||
name: packageName,
|
||||
exports: options.invariantExport === false ? {} : {
|
||||
'./invariant': {
|
||||
types: './lib/types/invariant.d.ts',
|
||||
default: './lib/invariant.js',
|
||||
},
|
||||
},
|
||||
files: ['lib/index.js', 'lib/invariant.js', 'src'],
|
||||
peerDependencies: options.invariantDependency === false ? {} : {
|
||||
'@deepseek-ai/dsh-invariants': '^0.0.1',
|
||||
},
|
||||
devDependencies: options.invariantDependency === false ? {} : {
|
||||
'@deepseek-ai/dsh-invariants': 'workspace:^',
|
||||
},
|
||||
}
|
||||
writeFileSync(join(dir, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`)
|
||||
writeFileSync(join(dir, 'tsconfig.json'), `${JSON.stringify({
|
||||
references: options.invariantReference === false ? [] : [{ path: '../../support/invariants' }],
|
||||
}, null, 2)}\n`)
|
||||
writeFileSync(join(dir, 'src/invariant.ts'), options.source ?? handwrittenInvariant(packageName))
|
||||
writeFileSync(
|
||||
join(dir, 'tsdown.config.ts'),
|
||||
options.buildEntry === false ? "export default { entry: ['lib/types/index.js'] }\n" : "export default { entry: ['lib/types/index.js', 'lib/types/invariant.js'] }\n",
|
||||
)
|
||||
return root
|
||||
}
|
||||
|
||||
describe('package invariant gate', () => {
|
||||
it('accepts a hand-owned checking companion with publication metadata', () => {
|
||||
expect(collectPackageInvariantViolations(fixture())).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects missing publication metadata and build output', () => {
|
||||
const violations = collectPackageInvariantViolations(fixture({
|
||||
invariantExport: false,
|
||||
invariantDependency: false,
|
||||
invariantReference: false,
|
||||
buildEntry: false,
|
||||
}))
|
||||
expect(violations.map(violation => violation.message)).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('exports["./invariant"]'),
|
||||
expect.stringContaining('peerDependency'),
|
||||
expect.stringContaining('devDependency'),
|
||||
expect.stringContaining('TypeScript project references'),
|
||||
expect.stringContaining('must bundle lib/types/invariant.js'),
|
||||
]))
|
||||
})
|
||||
|
||||
it('rejects foreign, duplicate, and unresolved registrations', () => {
|
||||
const source = `
|
||||
export const name = 'probe-invariant'
|
||||
export const inject = ['invariants']
|
||||
const selected = process.env.PACKAGE_NAME
|
||||
const install = (_ctx: unknown, fail: (message: string) => never) => { fail('probe') }
|
||||
export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) => {
|
||||
ctx.invariants.register('@deepseek-ai/dsh-foreign', install)
|
||||
return ctx.invariants.register(selected!, install)
|
||||
}
|
||||
`
|
||||
const violations = collectPackageInvariantViolations(fixture({ source }))
|
||||
expect(violations.map(violation => violation.message)).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('must resolve to a local string constant'),
|
||||
expect.stringContaining('must register exactly its own package name'),
|
||||
]))
|
||||
})
|
||||
|
||||
it('rejects generated markers and reporter-free executable installers', () => {
|
||||
const generated = fixture({
|
||||
source: `/** @generated */\n${handwrittenInvariant('@deepseek-ai/dsh-probe')}`,
|
||||
})
|
||||
expect(collectPackageInvariantViolations(generated).map(violation => violation.message))
|
||||
.toContain('invariant companions must be hand-owned and may not carry @generated markers')
|
||||
|
||||
const reporterFree = fixture({
|
||||
source: `
|
||||
export const name = 'probe-invariant'
|
||||
export const inject = ['invariants']
|
||||
const install = () => { void 0 }
|
||||
export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) =>
|
||||
Promise.resolve(ctx.invariants.register('@deepseek-ai/dsh-probe', install))
|
||||
`,
|
||||
})
|
||||
expect(collectPackageInvariantViolations(reporterFree).map(violation => violation.message))
|
||||
.toContain('install function must accept the bound failure reporter as its second parameter')
|
||||
|
||||
const unused = fixture({
|
||||
source: `
|
||||
export const name = 'probe-invariant'
|
||||
export const inject = ['invariants']
|
||||
const install = (_ctx: unknown, _fail: (message: string) => never) => { void 0 }
|
||||
export const apply = (ctx: { invariants: { register(name: string, install: typeof install): () => void } }) =>
|
||||
Promise.resolve(ctx.invariants.register('@deepseek-ai/dsh-probe', install))
|
||||
`,
|
||||
})
|
||||
expect(collectPackageInvariantViolations(unused).map(violation => violation.message))
|
||||
.toContain('install function must use its bound failure reporter')
|
||||
})
|
||||
|
||||
it('rejects registering a different installer than the checked local function', () => {
|
||||
const decoy = fixture({
|
||||
source: `
|
||||
export const name = 'probe-invariant'
|
||||
export const inject = ['invariants']
|
||||
const install = (_ctx: unknown, fail: (message: string) => never) => { fail('checked decoy') }
|
||||
export const apply = (ctx: { invariants: { register(name: string, install: () => void): () => void } }) =>
|
||||
ctx.invariants.register('@deepseek-ai/dsh-probe', () => {})
|
||||
`,
|
||||
})
|
||||
expect(collectPackageInvariantViolations(decoy).map(violation => violation.message))
|
||||
.toContain('line 6: ctx.invariants.register must use the checked local install function')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'export default { name, inject, apply }',
|
||||
"export * as default from './probe.ts'",
|
||||
])('rejects a default export that would collapse the Loader namespace', (defaultExport) => {
|
||||
const source = `${handwrittenInvariant('@deepseek-ai/dsh-probe')}\n${defaultExport}\n`
|
||||
expect(collectPackageInvariantViolations(fixture({ source })).map(violation => violation.message))
|
||||
.toContain('must not default-export; Loader must retain the companion namespace')
|
||||
})
|
||||
|
||||
it('accepts explained empty installers and rejects unexplained ones', () => {
|
||||
const explained = `
|
||||
export const name = 'probe-invariant'
|
||||
export const inject = ['invariants']
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-probe'
|
||||
/** No runtime invariant: this pure package owns no events or mutable data. */
|
||||
const install = () => {}
|
||||
export const apply = (ctx: { invariants: { register(name: string, install: () => void): () => void } }) =>
|
||||
ctx.invariants.register(PACKAGE_NAME, install)
|
||||
`
|
||||
expect(collectPackageInvariantViolations(fixture({ source: explained }))).toEqual([])
|
||||
|
||||
const unexplained = `
|
||||
export const name = 'probe-invariant'
|
||||
export const inject = ['invariants']
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-probe'
|
||||
const install = () => {}
|
||||
export const apply = (ctx: { invariants: { register(name: string, install: () => void): () => void } }) =>
|
||||
ctx.invariants.register(PACKAGE_NAME, install)
|
||||
`
|
||||
expect(collectPackageInvariantViolations(fixture({ source: unexplained })).map(violation => violation.message))
|
||||
.toContain('empty install function must explain why with a "No runtime invariant:" comment')
|
||||
})
|
||||
})
|
||||
340
scripts/package-invariants.ts
Normal file
340
scripts/package-invariants.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* Package-invariant companion discovery and structural checks.
|
||||
* The runtime registry stays product-independent; this gate makes ownership
|
||||
* exhaustive across packages without centralizing package checks.
|
||||
*/
|
||||
|
||||
import { existsSync, globSync, readFileSync } from 'node:fs'
|
||||
import { dirname, relative, resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
|
||||
/** Required explanation marker for an intentionally empty installer. */
|
||||
const NO_RUNTIME_INVARIANT_MARKER = 'No runtime invariant:'
|
||||
|
||||
interface PackageManifest {
|
||||
name?: string
|
||||
exports?: Record<string, { types?: string; default?: string } | string | undefined>
|
||||
files?: string[]
|
||||
peerDependencies?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
}
|
||||
|
||||
/** One package and the files participating in its invariant publication contract. */
|
||||
export interface PackageInvariantOwner {
|
||||
readonly dir: string
|
||||
readonly manifestPath: string
|
||||
readonly sourcePath: string
|
||||
readonly packageName: string
|
||||
}
|
||||
|
||||
/** One gate violation with a repo-relative owner path. */
|
||||
export interface PackageInvariantViolation {
|
||||
readonly path: string
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
/** Discover every package under the repository package tree. */
|
||||
export function packageInvariantOwners(root: string): PackageInvariantOwner[] {
|
||||
return globSync('packages/*/*/package.json', { cwd: root })
|
||||
.map(path => path.split(sep).join('/'))
|
||||
.sort()
|
||||
.map((manifestPath) => {
|
||||
const manifest = readManifest(resolve(root, manifestPath))
|
||||
if (manifest.name === undefined || manifest.name === '') {
|
||||
throw new Error(`${manifestPath}: package invariant owner must declare a package name`)
|
||||
}
|
||||
const dir = dirname(manifestPath)
|
||||
return {
|
||||
dir,
|
||||
manifestPath,
|
||||
sourcePath: `${dir}/src/invariant.ts`,
|
||||
packageName: manifest.name,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Return all violations of the package-invariant companion contract. */
|
||||
export function collectPackageInvariantViolations(root: string): PackageInvariantViolation[] {
|
||||
const violations: PackageInvariantViolation[] = []
|
||||
for (const owner of packageInvariantOwners(root)) {
|
||||
const manifest = readManifest(resolve(root, owner.manifestPath))
|
||||
checkManifest(owner, manifest, violations)
|
||||
checkBuild(owner, root, violations)
|
||||
checkSource(owner, root, violations)
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
function readManifest(path: string): PackageManifest {
|
||||
return JSON.parse(readFileSync(path, 'utf8')) as PackageManifest
|
||||
}
|
||||
|
||||
function addViolation(
|
||||
violations: PackageInvariantViolation[],
|
||||
path: string,
|
||||
message: string,
|
||||
): void {
|
||||
violations.push({ path, message })
|
||||
}
|
||||
|
||||
function checkManifest(
|
||||
owner: PackageInvariantOwner,
|
||||
manifest: PackageManifest,
|
||||
violations: PackageInvariantViolation[],
|
||||
): void {
|
||||
const invariantExport = manifest.exports?.['./invariant']
|
||||
if (typeof invariantExport !== 'object'
|
||||
|| invariantExport.types !== './lib/types/invariant.d.ts'
|
||||
|| invariantExport.default !== './lib/invariant.js') {
|
||||
addViolation(
|
||||
violations,
|
||||
owner.manifestPath,
|
||||
'exports["./invariant"] must target ./lib/types/invariant.d.ts and ./lib/invariant.js',
|
||||
)
|
||||
}
|
||||
if (!manifest.files?.includes('lib/invariant.js')) {
|
||||
addViolation(violations, owner.manifestPath, 'files must publish lib/invariant.js')
|
||||
}
|
||||
if (owner.packageName === '@deepseek-ai/dsh-invariants') return
|
||||
if (manifest.peerDependencies?.['@deepseek-ai/dsh-invariants'] !== '^0.0.1') {
|
||||
addViolation(
|
||||
violations,
|
||||
owner.manifestPath,
|
||||
'@deepseek-ai/dsh-invariants must be a ^0.0.1 peerDependency',
|
||||
)
|
||||
}
|
||||
if (manifest.devDependencies?.['@deepseek-ai/dsh-invariants'] !== 'workspace:^') {
|
||||
addViolation(
|
||||
violations,
|
||||
owner.manifestPath,
|
||||
'@deepseek-ai/dsh-invariants must also be a workspace:^ devDependency',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function checkBuild(
|
||||
owner: PackageInvariantOwner,
|
||||
root: string,
|
||||
violations: PackageInvariantViolation[],
|
||||
): void {
|
||||
const tsconfigPath = `${owner.dir}/tsconfig.json`
|
||||
const tsconfig = JSON.parse(readFileSync(resolve(root, tsconfigPath), 'utf8')) as {
|
||||
references?: Array<{ path?: string }>
|
||||
}
|
||||
if (owner.packageName !== '@deepseek-ai/dsh-invariants'
|
||||
&& !tsconfig.references?.some(reference => reference.path === '../../support/invariants')) {
|
||||
addViolation(
|
||||
violations,
|
||||
tsconfigPath,
|
||||
'TypeScript project references must include ../../support/invariants',
|
||||
)
|
||||
}
|
||||
|
||||
const configPath = `${owner.dir}/tsdown.config.ts`
|
||||
if (!existsSync(resolve(root, configPath))) return
|
||||
const source = readFileSync(resolve(root, configPath), 'utf8')
|
||||
if (!source.includes('lib/types/invariant.js')) {
|
||||
addViolation(violations, configPath, 'package build override must bundle lib/types/invariant.js')
|
||||
}
|
||||
}
|
||||
|
||||
function checkSource(
|
||||
owner: PackageInvariantOwner,
|
||||
root: string,
|
||||
violations: PackageInvariantViolation[],
|
||||
): void {
|
||||
const absolutePath = resolve(root, owner.sourcePath)
|
||||
if (!existsSync(absolutePath)) {
|
||||
addViolation(violations, owner.sourcePath, 'missing package-owned invariant companion')
|
||||
return
|
||||
}
|
||||
const sourceText = readFileSync(absolutePath, 'utf8')
|
||||
if (sourceText.includes('@generated')) {
|
||||
addViolation(
|
||||
violations,
|
||||
owner.sourcePath,
|
||||
'invariant companions must be hand-owned and may not carry @generated markers',
|
||||
)
|
||||
}
|
||||
|
||||
const sourceFile = ts.createSourceFile(
|
||||
absolutePath,
|
||||
sourceText,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TS,
|
||||
)
|
||||
const constants = topLevelStringConstants(sourceFile)
|
||||
const registrations: string[] = []
|
||||
const unresolved: number[] = []
|
||||
const mismatchedInstallers: number[] = []
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isCallExpression(node) && isInvariantRegistration(node.expression)) {
|
||||
const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1
|
||||
const argument = node.arguments[0]
|
||||
const packageName = argument === undefined ? undefined : stringValue(argument, constants)
|
||||
if (packageName === undefined) unresolved.push(line)
|
||||
else registrations.push(packageName)
|
||||
const installer = node.arguments[1]
|
||||
if (installer === undefined || !ts.isIdentifier(installer) || installer.text !== 'install') {
|
||||
mismatchedInstallers.push(line)
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(sourceFile)
|
||||
|
||||
for (const line of unresolved) {
|
||||
addViolation(
|
||||
violations,
|
||||
owner.sourcePath,
|
||||
`line ${line}: ctx.invariants.register package name must resolve to a local string constant`,
|
||||
)
|
||||
}
|
||||
for (const line of mismatchedInstallers) {
|
||||
addViolation(
|
||||
violations,
|
||||
owner.sourcePath,
|
||||
`line ${line}: ctx.invariants.register must use the checked local install function`,
|
||||
)
|
||||
}
|
||||
if (registrations.length !== 1 || registrations[0] !== owner.packageName) {
|
||||
addViolation(
|
||||
violations,
|
||||
owner.sourcePath,
|
||||
`must register exactly its own package name ${JSON.stringify(owner.packageName)}; saw ${JSON.stringify(registrations)}`,
|
||||
)
|
||||
}
|
||||
for (const exportedName of ['name', 'inject', 'apply']) {
|
||||
if (!hasNamedExport(sourceFile, exportedName)) {
|
||||
addViolation(violations, owner.sourcePath, `must named-export ${exportedName}`)
|
||||
}
|
||||
}
|
||||
if (hasDefaultExport(sourceFile)) {
|
||||
addViolation(violations, owner.sourcePath, 'must not default-export; Loader must retain the companion namespace')
|
||||
}
|
||||
checkInstaller(owner, sourceFile, sourceText, violations)
|
||||
}
|
||||
|
||||
function checkInstaller(
|
||||
owner: PackageInvariantOwner,
|
||||
sourceFile: ts.SourceFile,
|
||||
sourceText: string,
|
||||
violations: PackageInvariantViolation[],
|
||||
): void {
|
||||
let initializer: ts.Expression | undefined
|
||||
let declarationStatement: ts.VariableStatement | undefined
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (!ts.isVariableStatement(statement)) continue
|
||||
for (const declaration of statement.declarationList.declarations) {
|
||||
if (ts.isIdentifier(declaration.name)
|
||||
&& declaration.name.text === 'install'
|
||||
&& declaration.initializer !== undefined) {
|
||||
initializer = declaration.initializer
|
||||
declarationStatement = statement
|
||||
}
|
||||
}
|
||||
}
|
||||
const installer = initializer === undefined ? undefined : installerFunction(initializer)
|
||||
if (installer === undefined) {
|
||||
addViolation(violations, owner.sourcePath, 'must declare a local install function for package-owned checks')
|
||||
return
|
||||
}
|
||||
if (ts.isBlock(installer.body) && installer.body.statements.length === 0) {
|
||||
const declarationText = declarationStatement === undefined
|
||||
? ''
|
||||
: sourceText.slice(declarationStatement.getFullStart(), declarationStatement.getEnd())
|
||||
if (!declarationText.includes(NO_RUNTIME_INVARIANT_MARKER)) {
|
||||
addViolation(
|
||||
violations,
|
||||
owner.sourcePath,
|
||||
`empty install function must explain why with a "${NO_RUNTIME_INVARIANT_MARKER}" comment`,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
const reporter = installer.parameters[1]?.name
|
||||
if (reporter === undefined || !ts.isIdentifier(reporter)) {
|
||||
addViolation(violations, owner.sourcePath, 'install function must accept the bound failure reporter as its second parameter')
|
||||
return
|
||||
}
|
||||
if (!usesIdentifier(installer.body, reporter.text)) {
|
||||
addViolation(violations, owner.sourcePath, 'install function must use its bound failure reporter')
|
||||
}
|
||||
}
|
||||
|
||||
function usesIdentifier(node: ts.Node, name: string): boolean {
|
||||
return ts.isIdentifier(node) && node.text === name
|
||||
|| node.getChildren().some(child => usesIdentifier(child, name))
|
||||
}
|
||||
|
||||
function installerFunction(
|
||||
initializer: ts.Expression,
|
||||
): ts.ArrowFunction | ts.FunctionExpression | undefined {
|
||||
if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) return initializer
|
||||
if (ts.isCallExpression(initializer)
|
||||
&& ts.isPropertyAccessExpression(initializer.expression)
|
||||
&& ts.isIdentifier(initializer.expression.expression)
|
||||
&& initializer.expression.expression.text === 'Object'
|
||||
&& initializer.expression.name.text === 'assign') {
|
||||
const target = initializer.arguments[0]
|
||||
if (target !== undefined && (ts.isArrowFunction(target) || ts.isFunctionExpression(target))) return target
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function topLevelStringConstants(sourceFile: ts.SourceFile): ReadonlyMap<string, string> {
|
||||
const constants = new Map<string, string>()
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (!ts.isVariableStatement(statement)) continue
|
||||
for (const declaration of statement.declarationList.declarations) {
|
||||
if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) continue
|
||||
const value = stringValue(declaration.initializer, constants)
|
||||
if (value !== undefined) constants.set(declaration.name.text, value)
|
||||
}
|
||||
}
|
||||
return constants
|
||||
}
|
||||
|
||||
function stringValue(node: ts.Expression, constants: ReadonlyMap<string, string>): string | undefined {
|
||||
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text
|
||||
if (ts.isIdentifier(node)) return constants.get(node.text)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isInvariantRegistration(expression: ts.LeftHandSideExpression): boolean {
|
||||
return ts.isPropertyAccessExpression(expression)
|
||||
&& expression.name.text === 'register'
|
||||
&& ts.isPropertyAccessExpression(expression.expression)
|
||||
&& expression.expression.name.text === 'invariants'
|
||||
}
|
||||
|
||||
function hasNamedExport(sourceFile: ts.SourceFile, name: string): boolean {
|
||||
return sourceFile.statements.some((statement) => {
|
||||
if (!ts.isVariableStatement(statement)
|
||||
|| !statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) return false
|
||||
return statement.declarationList.declarations.some(declaration => ts.isIdentifier(declaration.name) && declaration.name.text === name)
|
||||
})
|
||||
}
|
||||
|
||||
function hasDefaultExport(sourceFile: ts.SourceFile): boolean {
|
||||
return sourceFile.statements.some((statement) => {
|
||||
if (ts.isExportAssignment(statement)) return true
|
||||
const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined
|
||||
if (modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword)) return true
|
||||
if (!ts.isExportDeclaration(statement) || statement.exportClause === undefined) return false
|
||||
if (ts.isNamespaceExport(statement.exportClause)) {
|
||||
return statement.exportClause.name.text === 'default'
|
||||
}
|
||||
return statement.exportClause.elements.some(element => element.name.text === 'default')
|
||||
})
|
||||
}
|
||||
|
||||
/** Format violations for the command-line gate. */
|
||||
export function formatPackageInvariantViolation(
|
||||
root: string,
|
||||
violation: PackageInvariantViolation,
|
||||
): string {
|
||||
const path = resolve(root, violation.path)
|
||||
return `${relative(root, path)}: ${violation.message}`
|
||||
}
|
||||
219
scripts/project-doc-site.spec.ts
Normal file
219
scripts/project-doc-site.spec.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
/** Tests for the documentation website projection adapter. */
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { docsPages, type DocsPage } from '../website/docs.ts'
|
||||
import { addProjectionFrontmatter, projectedPageContent, rewriteMarkdown } from './project-doc-site.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function fixture(): { root: string; pages: DocsPage[] } {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-doc-site-'))
|
||||
roots.push(root)
|
||||
mkdirSync(join(root, 'docs'), { recursive: true })
|
||||
mkdirSync(join(root, 'packages'), { recursive: true })
|
||||
writeFileSync(join(root, 'docs/a.md'), '# A\n')
|
||||
writeFileSync(join(root, 'docs/b.md'), '# B\n')
|
||||
writeFileSync(join(root, 'docs/x(y).md'), '# Parentheses\n')
|
||||
writeFileSync(join(root, 'packages/tool.ts'), 'one\ntwo\n')
|
||||
writeFileSync(join(root, 'packages/logo.svg'), '<svg/>\n')
|
||||
return {
|
||||
root,
|
||||
pages: [
|
||||
{ locale: 'root', contentLocale: 'en-US', source: 'docs/a.md', route: 'a.md', label: 'A', sidebar: 'zh-reference', section: 'Test', order: 1 },
|
||||
{ locale: 'root', contentLocale: 'en-US', source: 'docs/b.md', route: 'reference-root/b.md', label: 'B', sidebar: 'zh-reference', section: 'Test', order: 2 },
|
||||
{ locale: 'en', contentLocale: 'en-US', source: 'docs/a.md', route: 'en/a.md', label: 'A', sidebar: 'en-reference', section: 'Test', order: 1 },
|
||||
{ locale: 'en', contentLocale: 'en-US', source: 'docs/b.md', route: 'en/reference/b.md', label: 'B', sidebar: 'en-reference', section: 'Test', order: 2 },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('rewriteMarkdown', () => {
|
||||
it('maps published pages and pins unpublished source links', () => {
|
||||
const { root, pages } = fixture()
|
||||
const source = '[B](b.md#part) [source](../packages/tool.ts:2) [web](https://example.com)\n'
|
||||
expect(rewriteMarkdown(source, {
|
||||
locale: 'en',
|
||||
sourcePath: 'docs/a.md',
|
||||
route: 'en/a.md',
|
||||
pages,
|
||||
repoRoot: root,
|
||||
repositoryRef: 'abc123',
|
||||
})).toBe(
|
||||
'[B](./reference/b.md#part) '
|
||||
+ '[source](https://github.com/deepseek-harness/deepseek-harness/blob/abc123/packages/tool.ts#L2) '
|
||||
+ '[web](https://example.com)\n',
|
||||
)
|
||||
})
|
||||
|
||||
it('selects the published target in the current site locale', () => {
|
||||
const { root, pages } = fixture()
|
||||
expect(rewriteMarkdown('[B](b.md)\n', {
|
||||
locale: 'root',
|
||||
sourcePath: 'docs/a.md',
|
||||
route: 'a.md',
|
||||
pages,
|
||||
repoRoot: root,
|
||||
repositoryRef: 'abc123',
|
||||
})).toBe('[B](./reference-root/b.md)\n')
|
||||
})
|
||||
|
||||
it('uses raw GitHub content for unpublished images', () => {
|
||||
const { root, pages } = fixture()
|
||||
expect(rewriteMarkdown('\n', {
|
||||
locale: 'en',
|
||||
sourcePath: 'docs/a.md',
|
||||
route: 'en/a.md',
|
||||
pages,
|
||||
repoRoot: root,
|
||||
repositoryRef: 'abc123',
|
||||
})).toBe('\n')
|
||||
})
|
||||
|
||||
it('does not rewrite Markdown-looking text inside code fences', () => {
|
||||
const { root, pages } = fixture()
|
||||
const source = '```md\n[B](b.md)\n```\n'
|
||||
expect(rewriteMarkdown(source, {
|
||||
locale: 'en',
|
||||
sourcePath: 'docs/a.md',
|
||||
route: 'en/a.md',
|
||||
pages,
|
||||
repoRoot: root,
|
||||
repositoryRef: 'abc123',
|
||||
})).toBe(source)
|
||||
})
|
||||
|
||||
it('replaces the destination token without changing repeated titles or escapes', () => {
|
||||
const { root, pages } = fixture()
|
||||
const source = '[title](b.md "b.md") [escaped](x\\(y\\).md)\n'
|
||||
expect(rewriteMarkdown(source, {
|
||||
locale: 'en',
|
||||
sourcePath: 'docs/a.md',
|
||||
route: 'en/a.md',
|
||||
pages,
|
||||
repoRoot: root,
|
||||
repositoryRef: 'abc123',
|
||||
})).toBe(
|
||||
'[title](./reference/b.md "b.md") '
|
||||
+ '[escaped](https://github.com/deepseek-harness/deepseek-harness/blob/abc123/docs/x(y).md)\n',
|
||||
)
|
||||
})
|
||||
|
||||
it('routes a pair switcher across locales while ordinary links stay in locale', () => {
|
||||
const { root, pages } = fixture()
|
||||
writeFileSync(join(root, 'docs/a.zh.md'), '# A\n')
|
||||
const paired = pages.filter(page => page.source !== 'docs/a.md')
|
||||
paired.push(
|
||||
{
|
||||
locale: 'root', contentLocale: 'zh-CN', source: 'docs/a.zh.md', sourceAliases: ['docs/a.md'],
|
||||
route: 'guide/a.md', label: 'A', sidebar: 'zh-guide', section: 'Test', order: 1,
|
||||
},
|
||||
{
|
||||
locale: 'en', contentLocale: 'en-US', source: 'docs/a.md', sourceAliases: ['docs/a.zh.md'],
|
||||
route: 'en/guide/a.md', label: 'A', sidebar: 'en-guide', section: 'Test', order: 1,
|
||||
},
|
||||
)
|
||||
expect(rewriteMarkdown('[English](a.md) [B](b.md)\n', {
|
||||
locale: 'root',
|
||||
sourcePath: 'docs/a.zh.md',
|
||||
route: 'guide/a.md',
|
||||
pages: paired,
|
||||
repoRoot: root,
|
||||
repositoryRef: 'abc123',
|
||||
})).toBe('[English](../en/guide/a.md) [B](../reference-root/b.md)\n')
|
||||
})
|
||||
|
||||
it('fails loud when a relative target is missing', () => {
|
||||
const { root, pages } = fixture()
|
||||
expect(() => rewriteMarkdown('[missing](missing.md)\n', {
|
||||
locale: 'en',
|
||||
sourcePath: 'docs/a.md',
|
||||
route: 'en/a.md',
|
||||
pages,
|
||||
repoRoot: root,
|
||||
repositoryRef: 'abc123',
|
||||
})).toThrow('links to missing path "missing.md"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('docsPages locale routes', () => {
|
||||
it('publishes every route in both locales and selects paired user sources', () => {
|
||||
const byRoute = new Map(docsPages.map(page => [page.route, page]))
|
||||
for (const page of docsPages.filter(page => page.locale === 'root')) {
|
||||
const counterpart = byRoute.get(`en/${page.route}`)
|
||||
expect(counterpart, page.route).toBeDefined()
|
||||
expect(counterpart?.locale).toBe('en')
|
||||
if (page.source.startsWith('docs/user/')) {
|
||||
expect(page.source).toMatch(/\.zh\.md$/)
|
||||
expect(page.contentLocale).toBe('zh-CN')
|
||||
expect(counterpart?.source).toBe(page.source.replace(/\.zh\.md$/, '.md'))
|
||||
expect(counterpart?.contentLocale).toBe('en-US')
|
||||
} else {
|
||||
expect(counterpart?.source).toBe(page.source)
|
||||
expect(counterpart?.contentLocale).toBe(page.contentLocale)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('publishes the Cordis core API under matching locale structures', () => {
|
||||
const files = ['context.md', 'events.md', 'fiber.md', 'registry.md', 'service.md']
|
||||
for (const file of files) {
|
||||
const root = docsPages.find(page => page.route === `reference/cordis-api/${file}`)
|
||||
const english = docsPages.find(page => page.route === `en/reference/cordis-api/${file}`)
|
||||
expect(root?.source).toBe(`docs/cordis-catalog/core/${file}`)
|
||||
expect(root?.section).toBe('Cordis API')
|
||||
expect(english?.source).toBe(root?.source)
|
||||
expect(english?.section).toBe('Cordis Core API')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('addProjectionFrontmatter', () => {
|
||||
it('adds frontmatter to an ordinary Markdown page', () => {
|
||||
expect(addProjectionFrontmatter('# Guide\n', 'docs/guide.md')).toBe(
|
||||
'---\neditSource: "docs/guide.md"\n---\n\n# Guide\n',
|
||||
)
|
||||
})
|
||||
|
||||
it('extends existing VitePress frontmatter', () => {
|
||||
expect(addProjectionFrontmatter('---\nlayout: home\n---\n', 'docs/index.md')).toBe(
|
||||
'---\neditSource: "docs/index.md"\nlayout: home\n---\n',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('projectedPageContent', () => {
|
||||
const page = (sidebar: DocsPage['sidebar']): DocsPage => ({
|
||||
locale: 'root',
|
||||
contentLocale: 'zh-CN',
|
||||
source: 'docs/index.zh.md',
|
||||
route: 'index.md',
|
||||
label: 'Home',
|
||||
sidebar,
|
||||
section: 'Home',
|
||||
order: 0,
|
||||
})
|
||||
|
||||
it('omits the source-only body from locale home pages', () => {
|
||||
expect(projectedPageContent(
|
||||
'---\nlayout: home\nhero:\n name: Harness\n---\n\n# Harness\n\n[English](index.md) | 中文\n',
|
||||
page(null),
|
||||
)).toBe('---\nlayout: home\nhero:\n name: Harness\n---\n')
|
||||
})
|
||||
|
||||
it('keeps the full body for ordinary pages', () => {
|
||||
const markdown = '---\ntitle: Guide\n---\n\n# Guide\n'
|
||||
expect(projectedPageContent(markdown, page('zh-guide'))).toBe(markdown)
|
||||
})
|
||||
|
||||
it('rejects a locale home source without frontmatter', () => {
|
||||
expect(() => projectedPageContent('# Harness\n', page(null)))
|
||||
.toThrow('locale home source "docs/index.zh.md" must start with YAML frontmatter')
|
||||
})
|
||||
})
|
||||
322
scripts/project-doc-site.ts
Normal file
322
scripts/project-doc-site.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Build-time projection from canonical repository Markdown into VitePress.
|
||||
*
|
||||
* The generated tree is disposable: sources stay in their owning `docs/`
|
||||
* tier, while this adapter rewrites cross-source links for the public site.
|
||||
*/
|
||||
|
||||
import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, extname, posix, relative, resolve, sep } from 'node:path'
|
||||
import { fromMarkdown } from 'mdast-util-from-markdown'
|
||||
import { gfmFromMarkdown } from 'mdast-util-gfm'
|
||||
import { gfm } from 'micromark-extension-gfm'
|
||||
import type { Nodes } from 'mdast'
|
||||
import { docsPages, type DocsLocale, type DocsPage } from '../website/docs.ts'
|
||||
|
||||
const REPOSITORY_URL = 'https://github.com/deepseek-harness/deepseek-harness'
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const generatedRoot = resolve(root, 'website/.generated')
|
||||
|
||||
interface Replacement {
|
||||
start: number
|
||||
end: number
|
||||
value: string
|
||||
}
|
||||
|
||||
interface DestinationRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
type RewritableNode = Extract<Nodes, { type: 'link' | 'image' | 'definition' }>
|
||||
|
||||
/** Inputs for rewriting one canonical Markdown page. */
|
||||
export interface RewriteMarkdownOptions {
|
||||
locale: DocsLocale
|
||||
sourcePath: string
|
||||
route: string
|
||||
pages: DocsPage[]
|
||||
repoRoot: string
|
||||
repositoryRef: string
|
||||
}
|
||||
|
||||
function repoPath(absPath: string, repoRoot: string): string {
|
||||
return relative(repoRoot, absPath).split(sep).join('/')
|
||||
}
|
||||
|
||||
function isExternalOrSiteAbsolute(url: string): boolean {
|
||||
return url.startsWith('#')
|
||||
|| url.startsWith('//')
|
||||
|| url.startsWith('/')
|
||||
|| /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)
|
||||
}
|
||||
|
||||
function skipWhitespace(source: string, start: number): number {
|
||||
let index = start
|
||||
while (/\s/.test(source[index] ?? '')) index += 1
|
||||
return index
|
||||
}
|
||||
|
||||
function labelEnd(source: string): number {
|
||||
const first = source.indexOf('[')
|
||||
if (first === -1) return -1
|
||||
let depth = 0
|
||||
for (let index = first; index < source.length; index += 1) {
|
||||
const char = source[index]
|
||||
if (char === '\\') {
|
||||
index += 1
|
||||
} else if (char === '[') {
|
||||
depth += 1
|
||||
} else if (char === ']') {
|
||||
depth -= 1
|
||||
if (depth === 0) return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function destinationRange(rawNode: string, type: 'link' | 'image' | 'definition'): DestinationRange {
|
||||
const endOfLabel = labelEnd(rawNode)
|
||||
if (endOfLabel === -1) {
|
||||
throw new Error(`project-doc-site: cannot locate label end in ${JSON.stringify(rawNode)}.`)
|
||||
}
|
||||
|
||||
let start: number
|
||||
if (type === 'definition') {
|
||||
const colon = rawNode.indexOf(':', endOfLabel + 1)
|
||||
if (colon === -1) {
|
||||
throw new Error(`project-doc-site: cannot locate definition separator in ${JSON.stringify(rawNode)}.`)
|
||||
}
|
||||
start = skipWhitespace(rawNode, colon + 1)
|
||||
} else {
|
||||
if (rawNode[endOfLabel + 1] !== '(') {
|
||||
throw new Error(`project-doc-site: cannot locate inline destination in ${JSON.stringify(rawNode)}.`)
|
||||
}
|
||||
start = skipWhitespace(rawNode, endOfLabel + 2)
|
||||
}
|
||||
|
||||
if (rawNode[start] === '<') {
|
||||
for (let index = start + 1; index < rawNode.length; index += 1) {
|
||||
if (rawNode[index] === '\\') index += 1
|
||||
else if (rawNode[index] === '>') return { start: start + 1, end: index }
|
||||
}
|
||||
throw new Error(`project-doc-site: cannot locate angle-bracket destination end in ${JSON.stringify(rawNode)}.`)
|
||||
}
|
||||
|
||||
let depth = 0
|
||||
for (let index = start; index < rawNode.length; index += 1) {
|
||||
const char = rawNode[index]
|
||||
if (char === '\\') {
|
||||
index += 1
|
||||
} else if (char === '(') {
|
||||
depth += 1
|
||||
} else if (char === ')') {
|
||||
if (depth === 0) return { start, end: index }
|
||||
depth -= 1
|
||||
} else if (/\s/.test(char ?? '') && depth === 0) {
|
||||
return { start, end: index }
|
||||
}
|
||||
}
|
||||
return { start, end: rawNode.length }
|
||||
}
|
||||
|
||||
function splitTarget(url: string): { path: string; suffix: string } {
|
||||
const boundary = url.search(/[?#]/)
|
||||
if (boundary === -1) return { path: url, suffix: '' }
|
||||
return { path: url.slice(0, boundary), suffix: url.slice(boundary) }
|
||||
}
|
||||
|
||||
function decodePath(path: string): string {
|
||||
try {
|
||||
return decodeURIComponent(path)
|
||||
} catch {
|
||||
throw new Error(`project-doc-site: malformed percent escape in ${JSON.stringify(path)}.`)
|
||||
}
|
||||
}
|
||||
|
||||
function routeTarget(fromRoute: string, toRoute: string, suffix: string): string {
|
||||
const target = posix.relative(posix.dirname(fromRoute), toRoute)
|
||||
return `${target.startsWith('.') ? target : `./${target}`}${suffix}`
|
||||
}
|
||||
|
||||
function sourceMap(pages: DocsPage[]): Map<string, Map<DocsLocale, DocsPage>> {
|
||||
const map = new Map<string, Map<DocsLocale, DocsPage>>()
|
||||
for (const page of pages) {
|
||||
for (const source of [page.source, ...(page.sourceAliases ?? [])]) {
|
||||
const localized = map.get(source) ?? new Map<DocsLocale, DocsPage>()
|
||||
if (localized.has(page.locale)) {
|
||||
throw new Error(`project-doc-site: duplicate source or alias ${JSON.stringify(source)} for locale ${JSON.stringify(page.locale)}.`)
|
||||
}
|
||||
localized.set(page.locale, page)
|
||||
map.set(source, localized)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function counterpartSource(source: string): string {
|
||||
return source.endsWith('.zh.md')
|
||||
? source.replace(/\.zh\.md$/, '.md')
|
||||
: source.replace(/\.md$/, '.zh.md')
|
||||
}
|
||||
|
||||
function resolveRepositoryTarget(sourceAbs: string, rawPath: string, repoRoot: string): { absPath: string; line?: number } {
|
||||
const decoded = decodePath(rawPath)
|
||||
let absPath = resolve(dirname(sourceAbs), decoded)
|
||||
if (existsSync(absPath)) return { absPath }
|
||||
|
||||
const lineMatch = decoded.match(/:(\d+)$/)
|
||||
if (lineMatch !== null) {
|
||||
const lineText = lineMatch[1]
|
||||
if (lineText === undefined) throw new Error('project-doc-site: line suffix matched without a line number.')
|
||||
absPath = resolve(dirname(sourceAbs), decoded.slice(0, -lineMatch[0].length))
|
||||
if (existsSync(absPath)) return { absPath, line: Number.parseInt(lineText, 10) }
|
||||
}
|
||||
|
||||
if (extname(decoded) === '') {
|
||||
const markdown = resolve(dirname(sourceAbs), `${decoded}.md`)
|
||||
if (existsSync(markdown)) return { absPath: markdown }
|
||||
const index = resolve(dirname(sourceAbs), decoded, 'index.md')
|
||||
if (existsSync(index)) return { absPath: index }
|
||||
}
|
||||
|
||||
throw new Error(`project-doc-site: ${repoPath(sourceAbs, repoRoot)} links to missing path ${JSON.stringify(rawPath)}.`)
|
||||
}
|
||||
|
||||
function githubTarget(
|
||||
absPath: string,
|
||||
line: number | undefined,
|
||||
suffix: string,
|
||||
repositoryRef: string,
|
||||
repoRoot: string,
|
||||
image: boolean,
|
||||
): string {
|
||||
const path = repoPath(absPath, repoRoot)
|
||||
if (image) return `https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/${repositoryRef}/${path}${suffix}`
|
||||
const kind = lstatSync(absPath).isDirectory() ? 'tree' : 'blob'
|
||||
const lineSuffix = line === undefined ? suffix : `#L${line}`
|
||||
return `${REPOSITORY_URL}/${kind}/${repositoryRef}/${path}${lineSuffix}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite repository-relative links without reserializing Markdown.
|
||||
*
|
||||
* @param source Markdown text from the canonical file.
|
||||
* @param options Source, route, manifest, and repository context.
|
||||
* @returns Markdown whose published links resolve inside the site or to GitHub.
|
||||
*/
|
||||
export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions): string {
|
||||
const sourceAbs = resolve(options.repoRoot, options.sourcePath)
|
||||
const published = sourceMap(options.pages)
|
||||
const tree = fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
|
||||
const replacements: Replacement[] = []
|
||||
|
||||
const rewrite = (node: RewritableNode): void => {
|
||||
if (isExternalOrSiteAbsolute(node.url)) return
|
||||
const { path, suffix } = splitTarget(node.url)
|
||||
if (path === '') return
|
||||
const { absPath, line } = resolveRepositoryTarget(sourceAbs, path, options.repoRoot)
|
||||
const targetPath = repoPath(absPath, options.repoRoot)
|
||||
const isLanguageSwitcher = targetPath === counterpartSource(options.sourcePath)
|
||||
const targetLocale: DocsLocale = isLanguageSwitcher
|
||||
? options.locale === 'root' ? 'en' : 'root'
|
||||
: options.locale
|
||||
const page = published.get(targetPath)?.get(targetLocale)
|
||||
const nextUrl = page === undefined
|
||||
? githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image')
|
||||
: routeTarget(options.route, page.route, suffix)
|
||||
|
||||
const start = node.position?.start.offset
|
||||
const end = node.position?.end.offset
|
||||
if (start === undefined || end === undefined) {
|
||||
throw new Error(`project-doc-site: link ${JSON.stringify(node.url)} has no source offsets.`)
|
||||
}
|
||||
const rawNode = source.slice(start, end)
|
||||
const rawDestination = destinationRange(rawNode, node.type)
|
||||
replacements.push({
|
||||
start: start + rawDestination.start,
|
||||
end: start + rawDestination.end,
|
||||
value: nextUrl,
|
||||
})
|
||||
}
|
||||
|
||||
const visit = (node: Nodes): void => {
|
||||
if ((node.type === 'link' || node.type === 'image' || node.type === 'definition') && 'url' in node) rewrite(node)
|
||||
if ('children' in node) {
|
||||
for (const child of node.children) visit(child)
|
||||
}
|
||||
}
|
||||
visit(tree)
|
||||
|
||||
let projected = source
|
||||
for (const replacement of replacements.sort((a, b) => b.start - a.start)) {
|
||||
projected = projected.slice(0, replacement.start) + replacement.value + projected.slice(replacement.end)
|
||||
}
|
||||
return projected
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the canonical edit target in VitePress frontmatter.
|
||||
*
|
||||
* @param markdown Projected Markdown content.
|
||||
* @param sourcePath Repository-relative canonical source path.
|
||||
* @returns Markdown with an `editSource` frontmatter field.
|
||||
*/
|
||||
export function addProjectionFrontmatter(markdown: string, sourcePath: string): string {
|
||||
const field = `editSource: ${JSON.stringify(sourcePath)}`
|
||||
if (markdown.startsWith('---\n')) return markdown.replace('---\n', `---\n${field}\n`)
|
||||
return `---\n${field}\n---\n\n${markdown}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the Markdown rendered for one published page.
|
||||
*
|
||||
* @param markdown Rewritten canonical Markdown content.
|
||||
* @param page Publication manifest entry for the content.
|
||||
* @returns Full Markdown for ordinary pages or frontmatter-only Markdown for a locale home page.
|
||||
*/
|
||||
export function projectedPageContent(markdown: string, page: DocsPage): string {
|
||||
if (page.sidebar !== null) return markdown
|
||||
if (!markdown.startsWith('---\n')) {
|
||||
throw new Error(`project-doc-site: locale home source ${JSON.stringify(page.source)} must start with YAML frontmatter.`)
|
||||
}
|
||||
const closingDelimiter = '\n---\n'
|
||||
const closing = markdown.indexOf(closingDelimiter, 4)
|
||||
if (closing === -1) {
|
||||
throw new Error(`project-doc-site: locale home source ${JSON.stringify(page.source)} has unclosed YAML frontmatter.`)
|
||||
}
|
||||
return markdown.slice(0, closing + closingDelimiter.length)
|
||||
}
|
||||
|
||||
/** Canonical Markdown files watched by the local VitePress dev server. */
|
||||
export function docsSourceFiles(): string[] {
|
||||
return [...new Set(docsPages.map(page => resolve(root, page.source)))]
|
||||
}
|
||||
|
||||
/** Rebuild the disposable VitePress source tree from the publication manifest. */
|
||||
export function projectDocs(): void {
|
||||
const routes = new Set<string>()
|
||||
const repositoryRef = process.env.GITHUB_SHA ?? 'master'
|
||||
rmSync(generatedRoot, { recursive: true, force: true })
|
||||
|
||||
for (const page of docsPages) {
|
||||
if (routes.has(page.route)) throw new Error(`project-doc-site: duplicate route ${JSON.stringify(page.route)}.`)
|
||||
routes.add(page.route)
|
||||
const sourceAbs = resolve(root, page.source)
|
||||
if (!existsSync(sourceAbs) || !lstatSync(sourceAbs).isFile()) {
|
||||
throw new Error(`project-doc-site: source ${JSON.stringify(page.source)} does not exist or is not a file.`)
|
||||
}
|
||||
const output = resolve(generatedRoot, page.route)
|
||||
mkdirSync(dirname(output), { recursive: true })
|
||||
const markdown = readFileSync(sourceAbs, 'utf8')
|
||||
const projected = rewriteMarkdown(markdown, {
|
||||
sourcePath: page.source,
|
||||
locale: page.locale,
|
||||
route: page.route,
|
||||
pages: docsPages,
|
||||
repoRoot: root,
|
||||
repositoryRef,
|
||||
})
|
||||
writeFileSync(output, addProjectionFrontmatter(projectedPageContent(projected, page), page.source))
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,8 @@
|
||||
* 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 { resolve } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
|
||||
type Mode =
|
||||
@@ -19,6 +18,8 @@ type Mode =
|
||||
| 'ci-artifacts'
|
||||
| 'node-compat'
|
||||
| 'pre-push'
|
||||
| 'manual-push'
|
||||
| 'doc-sync'
|
||||
type GateStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped'
|
||||
|
||||
interface Gate {
|
||||
@@ -88,21 +89,25 @@ function parseMode(raw: string | undefined): Mode {
|
||||
case 'ci-artifacts':
|
||||
case 'node-compat':
|
||||
case 'pre-push':
|
||||
case 'doc-sync':
|
||||
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)}.`,
|
||||
`run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | node-compat | pre-push | doc-sync, got ${JSON.stringify(raw)}.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function defaultConcurrency(selectedMode: Mode, total: number): ConcurrencyDefault {
|
||||
const available = availableParallelism()
|
||||
const modeLimit = selectedMode === 'pre-push' ? Math.min(4, available) : available
|
||||
// Local modes cap workers: several doc gates each build a full ts.Program,
|
||||
// so an uncapped default on a large host trades wall clock for memory blowups.
|
||||
const localCap = selectedMode === 'pre-push' || selectedMode === 'doc-sync'
|
||||
const modeLimit = localCap ? Math.min(4, available) : available
|
||||
return {
|
||||
workers: Math.min(total, modeLimit),
|
||||
source: selectedMode === 'pre-push'
|
||||
? `${available} available CPU(s), pre-push cap 4`
|
||||
source: localCap
|
||||
? `${available} available CPU(s), ${selectedMode} cap 4`
|
||||
: `${available} available CPU(s)`,
|
||||
}
|
||||
}
|
||||
@@ -181,15 +186,23 @@ function gatesForMode(selected: Mode): Gate[] {
|
||||
'run',
|
||||
'packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts',
|
||||
], { label: 'source worker smoke' }),
|
||||
pnpmExec('jsonl-zstd-smoke', [
|
||||
'vitest',
|
||||
'run',
|
||||
'packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts',
|
||||
], { label: 'JSONL Zstandard smoke' }),
|
||||
]
|
||||
case 'pre-push':
|
||||
case 'pre-push': return []
|
||||
case 'manual-push':
|
||||
return [
|
||||
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
|
||||
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
|
||||
pnpmScript('client-domain-graph', 'verify-client-domain-graph', { label: 'client domain graph' }),
|
||||
pnpmScript('test', 'test'),
|
||||
pnpmScript('duplication', 'duplication'),
|
||||
snapshotGate(),
|
||||
pnpmScript('build', 'build'),
|
||||
pnpmScript('build:web', 'build:web'),
|
||||
...hygieneLeafGates({ artifactNeeds: ['build'] }),
|
||||
...docSyncLeafGates({
|
||||
docTypecheckNeeds: ['build'],
|
||||
@@ -197,6 +210,8 @@ function gatesForMode(selected: Mode): Gate[] {
|
||||
}),
|
||||
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
|
||||
]
|
||||
case 'doc-sync':
|
||||
return docSyncLeafGates()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,23 +219,23 @@ function ciPrimaryGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
|
||||
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
|
||||
pnpmScript('typecheck', 'typecheck'),
|
||||
lintGate(),
|
||||
pnpmScript('duplication', 'duplication'),
|
||||
coverageGate(),
|
||||
snapshotGate(),
|
||||
demoSmokeGate({ needs: ['lint'] }),
|
||||
...docSyncLeafGates(),
|
||||
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
|
||||
pnpmScript('knip', 'knip'),
|
||||
pnpmScript('website-build', 'website:build', { label: 'website build' }),
|
||||
pnpmScript('build', 'build', { needs: ['typecheck'] }),
|
||||
pnpmScript('publint', 'publint', { needs: ['build'] }),
|
||||
pnpmScript('node-next-types', 'verify-node-next-types', {
|
||||
label: 'node-next types',
|
||||
needs: ['build'],
|
||||
}),
|
||||
builtPackageInvariantsGate(['build']),
|
||||
builtBinSmokeGate(),
|
||||
]
|
||||
}
|
||||
@@ -229,20 +244,14 @@ function ciStaticGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
|
||||
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
|
||||
...staticDemoSmokeGates(),
|
||||
...docSyncLeafGates(),
|
||||
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
|
||||
pnpmScript('knip', 'knip'),
|
||||
pnpmScript('website-build', 'website:build', { label: 'website build' }),
|
||||
]
|
||||
}
|
||||
|
||||
function staticDemoSmokeGates(): Gate[] {
|
||||
// Native Windows session persistence is outside the gates-only support scope.
|
||||
return process.platform === 'win32' ? [] : [demoSmokeGate()]
|
||||
}
|
||||
|
||||
function ciArtifactGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('build', 'build'),
|
||||
@@ -251,6 +260,7 @@ function ciArtifactGates(): Gate[] {
|
||||
label: 'node-next types',
|
||||
needs: ['build'],
|
||||
}),
|
||||
builtPackageInvariantsGate(['build']),
|
||||
builtBinSmokeGate(),
|
||||
]
|
||||
}
|
||||
@@ -298,6 +308,13 @@ function snapshotGate(): Gate {
|
||||
})
|
||||
}
|
||||
|
||||
function builtPackageInvariantsGate(needs?: string[]): Gate {
|
||||
return pnpmScript('built-package-invariants', 'verify-built-package-invariants', {
|
||||
label: 'built package invariants',
|
||||
...needs === undefined ? {} : { needs },
|
||||
})
|
||||
}
|
||||
|
||||
function positiveIntArg(envName: string, flag: string): string[] {
|
||||
const raw = process.env[envName]
|
||||
if (raw === undefined || raw === '') return []
|
||||
@@ -314,6 +331,8 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
|
||||
pnpmScript('knip', 'knip'),
|
||||
pnpmScript('publint', 'publint', artifactOptions),
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
|
||||
builtPackageInvariantsGate(options.artifactNeeds),
|
||||
pnpmScript('node-next-types', 'verify-node-next-types', {
|
||||
label: 'node-next types',
|
||||
...artifactOptions,
|
||||
@@ -331,13 +350,13 @@ function docSyncLeafGates(options: {
|
||||
return [
|
||||
pnpmScript('doc-typecheck', 'doc-typecheck', docTypecheckOptions),
|
||||
pnpmScript('cordis-catalog', 'verify-cordis-catalog', { label: 'cordis catalog' }),
|
||||
pnpmScript('cordis-api', 'verify-cordis-api', { label: 'cordis api' }),
|
||||
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('scoped-events', 'verify-scoped-events', { label: 'scoped events' }),
|
||||
pnpmScript('website-api', 'verify-website-api', { label: 'website api' }),
|
||||
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' }),
|
||||
@@ -350,55 +369,20 @@ function docSyncLeafGates(options: {
|
||||
pnpmScript('translation-prompt', 'verify-translation-prompt', { label: 'translation prompt' }),
|
||||
pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }),
|
||||
pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }),
|
||||
// Keep the VitePress build in this single gate because projection rewrites website/.generated.
|
||||
pnpmScript('docs-site', 'docs:check', { label: 'documentation site' }),
|
||||
pnpmScript('package-readme-limitations', 'verify-package-readme-limitations', { label: 'package README limitations' }),
|
||||
pnpmScript('website-yaml', 'verify-website-yaml', { label: 'website yaml' }),
|
||||
]
|
||||
}
|
||||
|
||||
function demoSmokeGate(options: { needs?: string[] } = {}): Gate {
|
||||
const dependencyOptions = options.needs === undefined ? {} : { needs: options.needs }
|
||||
return {
|
||||
id: 'demo-smoke',
|
||||
label: 'demo smoke',
|
||||
displayCommand: 'pnpm run demo:echo',
|
||||
...pnpmInvocation(['run', 'demo:echo']),
|
||||
input: 'echo ci smoke\n',
|
||||
...dependencyOptions,
|
||||
verify: async (result) => {
|
||||
const output = result.stdout + result.stderr
|
||||
const sessionsRoot = join(root, '.sessions')
|
||||
try {
|
||||
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 buckets = await readdir(sessionsRoot, { withFileTypes: true })
|
||||
let found = false
|
||||
for (const bucket of buckets) {
|
||||
if (!bucket.isDirectory() || !bucket.name.startsWith('cwd-')) continue
|
||||
const entries = await readdir(join(sessionsRoot, bucket.name))
|
||||
if (entries.some(entry => /^main-session-.+\.jsonl$/.test(entry))) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!found) throw new Error('demo smoke did not create a main-session JSONL log in a cwd bucket.')
|
||||
} finally {
|
||||
await rm(sessionsRoot, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function builtBinSmokeGate(): Gate {
|
||||
return pnpmExec('built-bin-smoke', [
|
||||
'vitest',
|
||||
'run',
|
||||
'--config',
|
||||
'vitest.e2e.config.ts',
|
||||
'packages/examples/stdio-demo/tests/built-bin.e2e.ts',
|
||||
'examples/headless-agent/tests/keyless-smoke.e2e.ts',
|
||||
'examples/tui-agent/tests/tui-keyless-smoke.e2e.ts',
|
||||
'packages/examples/cli-demo/tests/built-bin.e2e.ts',
|
||||
'packages/examples/acp-demo/tests/built-bin.e2e.ts',
|
||||
'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts',
|
||||
@@ -410,6 +394,7 @@ function builtBinSmokeGate(): Gate {
|
||||
], {
|
||||
label: 'built-bin smoke',
|
||||
needs: ['build'],
|
||||
env: { DSH_EXAMPLE_MODE: 'lib' },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ CUSTOM_CORDIS = """\
|
||||
name: '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
config:
|
||||
root: !!js process.env.DSH_SESSION_ROOT
|
||||
compression: 'none'
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
config:
|
||||
@@ -391,7 +392,7 @@ def smoke_sdk_default(base_url: str) -> None:
|
||||
result = harness.run("reply with the smoke text", session_id="default-smoke")
|
||||
assert result.status == "ok", result
|
||||
assert result.final_response == EXPECTED_TEXT, result.final_response
|
||||
assert_session_log(sessions, root, EXPECTED_TEXT)
|
||||
assert_zstd_session_log(sessions)
|
||||
|
||||
|
||||
def smoke_sdk_custom(base_url: str, executable: Path) -> None:
|
||||
@@ -585,6 +586,14 @@ def assert_session_log(sessions: Path, cwd: Path, *expected_texts: str) -> None:
|
||||
raise AssertionError(f"session log has no {expected!r} response: {logs[0]}")
|
||||
|
||||
|
||||
def assert_zstd_session_log(sessions: Path) -> None:
|
||||
logs = list(sessions.rglob("*.jsonl.zstd"))
|
||||
if len(logs) != 1:
|
||||
raise AssertionError(f"expected one Zstandard JSONL session log under {sessions}, found {logs}")
|
||||
if not logs[0].read_bytes().startswith(bytes.fromhex("28b52ffd")):
|
||||
raise AssertionError(f"session log has no Zstandard magic: {logs[0]}")
|
||||
|
||||
|
||||
def read_session_logs(sessions: Path) -> dict[str, list[dict[str, object]]]:
|
||||
"""Parse every persisted JSONL session into a map keyed by header id."""
|
||||
logs: dict[str, list[dict[str, object]]] = {}
|
||||
|
||||
87
scripts/test-invariants.spec.ts
Normal file
87
scripts/test-invariants.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context, Service } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import { packageInvariantOwners } from './package-invariants.ts'
|
||||
import {
|
||||
testInvariantCompanionPaths,
|
||||
testInvariantCompanions,
|
||||
usesManualInvariantTree,
|
||||
} from './test-invariants.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
testInvariantProbe: TestInvariantProbe
|
||||
}
|
||||
}
|
||||
|
||||
class TestInvariantProbe extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'testInvariantProbe')
|
||||
}
|
||||
}
|
||||
|
||||
describe('global test invariant host', () => {
|
||||
it('uses one exhaustive topology to reserve every package name with enabled checks', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TestInvariantProbe)
|
||||
|
||||
const owners = packageInvariantOwners(process.cwd())
|
||||
expect(Object.keys(testInvariantCompanions)).toHaveLength(owners.length)
|
||||
const unreserved: string[] = []
|
||||
for (const owner of owners) {
|
||||
try {
|
||||
const dispose = ctx.invariants.register(owner.packageName, () => {})
|
||||
unreserved.push(owner.packageName)
|
||||
dispose()
|
||||
} catch (error) {
|
||||
expect(error).toHaveProperty(
|
||||
'message',
|
||||
`invariants: package "${owner.packageName}" is already registered`,
|
||||
)
|
||||
}
|
||||
}
|
||||
expect(unreserved).toEqual([])
|
||||
})
|
||||
|
||||
it('mounts the owning package companion while leaving non-package roots service-only', () => {
|
||||
expect(testInvariantCompanionPaths('/repo/packages/core/tools/tests/tools.spec.ts'))
|
||||
.toEqual(['../packages/core/tools/src/invariant.ts'])
|
||||
expect(testInvariantCompanionPaths('/repo/examples/echo-agent/tests/echo.spec.ts')).toEqual([])
|
||||
expect(testInvariantCompanionPaths('/repo/scripts/test-invariants.spec.ts'))
|
||||
.toEqual(Object.keys(testInvariantCompanions).sort())
|
||||
})
|
||||
|
||||
it('loads and executes every source companion through the real Loader shape', async () => {
|
||||
const owners = new Map(packageInvariantOwners(process.cwd()).map(owner => [owner.sourcePath, owner.packageName]))
|
||||
const registrations = new Map<string, string>()
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const register = vi.fn((_packageName: string, installer: InvariantInstaller) => {
|
||||
expect(typeof installer).toBe('function')
|
||||
return () => {}
|
||||
})
|
||||
const fakeContext = { invariants: { register } } as unknown as Context
|
||||
for (const [rawPath, companion] of Object.entries(testInvariantCompanions)) {
|
||||
const path = rawPath.replace(/^\.\.\//, '')
|
||||
expect(companion.default, path).toBeUndefined()
|
||||
const unwrapped = loader.unwrapExports(companion) as typeof companion
|
||||
expect(unwrapped, path).toBe(companion)
|
||||
expect(typeof unwrapped.name, path).toBe('string')
|
||||
expect(unwrapped.inject, path).toContain('invariants')
|
||||
expect(typeof unwrapped.apply, path).toBe('function')
|
||||
await unwrapped.apply(fakeContext)
|
||||
const call = register.mock.calls.at(-1)
|
||||
if (call === undefined) throw new Error(`${path}: companion did not register`)
|
||||
registrations.set(path, call[0])
|
||||
}
|
||||
expect(registrations).toEqual(owners)
|
||||
})
|
||||
|
||||
it('recognizes focused invariant suites without a package inventory', () => {
|
||||
expect(usesManualInvariantTree('/repo/packages/core/session/tests/invariant.spec.ts')).toBe(true)
|
||||
expect(usesManualInvariantTree('/repo/packages/core/session/tests/request-invariant-hmr.spec.ts')).toBe(true)
|
||||
expect(usesManualInvariantTree('C:\\repo\\packages\\support\\invariants\\tests\\service.spec.ts')).toBe(true)
|
||||
expect(usesManualInvariantTree('/repo/packages/examples/agent-spine-demo/tests/agent-core.spec.ts')).toBe(true)
|
||||
expect(usesManualInvariantTree('/repo/packages/core/session/tests/session.spec.ts')).toBe(false)
|
||||
})
|
||||
})
|
||||
150
scripts/test-invariants.ts
Normal file
150
scripts/test-invariants.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Vitest-wide invariant host. Ordinary Cordis roots receive the invariant
|
||||
* service with global enablement plus the current test package's companion.
|
||||
* One topology test mounts every companion; focused invariant tests own their
|
||||
* service topology explicitly.
|
||||
*/
|
||||
|
||||
import { expect } from 'vitest'
|
||||
import { RegistryService } from 'cordis'
|
||||
import type { Context, Plugin } from 'cordis'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
declare global {
|
||||
interface ImportMeta {
|
||||
/** Eager Vite module-glob expansion used by the Vitest setup file. */
|
||||
glob<TModule>(pattern: string, options: { eager: true }): Record<string, TModule>
|
||||
}
|
||||
}
|
||||
|
||||
/** Loader-safe shape shared by every package invariant companion. */
|
||||
export interface TestInvariantCompanion {
|
||||
readonly name: string
|
||||
readonly inject: readonly string[]
|
||||
readonly default?: unknown
|
||||
apply(ctx: Context): Promise<() => void>
|
||||
}
|
||||
|
||||
/** Every package companion, discovered eagerly so coverage observes each registration. */
|
||||
export const testInvariantCompanions: Readonly<Record<string, TestInvariantCompanion>> =
|
||||
import.meta.glob<TestInvariantCompanion>('../packages/*/*/src/invariant.ts', { eager: true })
|
||||
|
||||
/** Manual-topology suites whose names cannot follow the focused invariant convention. */
|
||||
const MANUAL_INVARIANT_TEST_EXCEPTIONS = [
|
||||
'/packages/support/invariants/tests/service.spec.ts',
|
||||
'/packages/examples/agent-spine-demo/tests/agent-core.spec.ts',
|
||||
] as const
|
||||
|
||||
interface InvariantHost {
|
||||
readonly fibers: readonly PluginFiber[]
|
||||
readonly byCallback: ReadonlyMap<unknown, PluginFiber>
|
||||
readonly ready: Promise<void>
|
||||
}
|
||||
|
||||
type PluginFiber = ReturnType<RegistryService['plugin']>
|
||||
|
||||
const hosts = new WeakMap<Context, InvariantHost>()
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method -- every call below supplies its RegistryService receiver explicitly.
|
||||
const originalPlugin = RegistryService.prototype.plugin
|
||||
|
||||
RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, getOuterStack?: () => string[]) {
|
||||
const testPath = expect.getState().testPath ?? ''
|
||||
if (usesManualInvariantTree(testPath)) return originalPlugin.call(this, plugin, config, getOuterStack)
|
||||
|
||||
const root = this.ctx.root
|
||||
const host = hosts.get(root) ?? startInvariantHost(root)
|
||||
const callback = this.resolve(plugin)
|
||||
const existing = callback === undefined ? undefined : host.byCallback.get(callback)
|
||||
if (existing !== undefined) {
|
||||
return this.ctx === root ? joinInvariantStartup(existing, host.ready) : existing
|
||||
}
|
||||
|
||||
const fiber = originalPlugin.call(this, plugin, config, getOuterStack)
|
||||
// A root-level await is the test's composition boundary. Nested plugin
|
||||
// fibers must not await their own companion parent through the global host.
|
||||
if (this.ctx !== root) return fiber
|
||||
return joinInvariantStartup(fiber, host.ready)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect focused suites that construct service selection or companion lifecycle explicitly.
|
||||
* @param testPath - absolute or repo-relative Vitest file path.
|
||||
* @returns whether the global invariant host must leave the root untouched.
|
||||
*/
|
||||
export function usesManualInvariantTree(testPath: string): boolean {
|
||||
const normalized = testPath.replaceAll('\\', '/')
|
||||
if (/\/packages\/[^/]+\/[^/]+\/tests\/[^/]*invariant[^/]*\.spec\.ts$/.test(normalized)) return true
|
||||
return MANUAL_INVARIANT_TEST_EXCEPTIONS.some(path => normalized.endsWith(path))
|
||||
}
|
||||
|
||||
const ALL_COMPANION_TESTS = ['/scripts/test-invariants.spec.ts'] as const
|
||||
|
||||
/**
|
||||
* Select the package companions that an ordinary test root must register.
|
||||
* Package tests receive their owner's checks; the dedicated topology test
|
||||
* receives every owner so coverage and exhaustive runtime registration remain
|
||||
* independently enforced.
|
||||
* @param testPath - absolute or repo-relative normalized Vitest file path.
|
||||
* @returns sorted `import.meta.glob` keys for companions to mount.
|
||||
*/
|
||||
export function testInvariantCompanionPaths(testPath: string): string[] {
|
||||
const normalized = testPath.replaceAll('\\', '/')
|
||||
const allPaths = Object.keys(testInvariantCompanions).sort()
|
||||
if (ALL_COMPANION_TESTS.some(path => normalized.endsWith(path))) return allPaths
|
||||
|
||||
const owner = normalized.match(/\/packages\/([^/]+)\/([^/]+)\/tests\//)
|
||||
if (owner === null) return []
|
||||
const companionPath = `../packages/${owner[1]}/${owner[2]}/src/invariant.ts`
|
||||
if (testInvariantCompanions[companionPath] === undefined) {
|
||||
throw new Error(`test invariants: package test has no companion at ${companionPath}`)
|
||||
}
|
||||
return [companionPath]
|
||||
}
|
||||
|
||||
function startInvariantHost(root: Context): InvariantHost {
|
||||
const fibers: PluginFiber[] = []
|
||||
const byCallback = new Map<unknown, PluginFiber>()
|
||||
const mount = (plugin: Plugin, config?: unknown): void => {
|
||||
const fiber = originalPlugin.call(root.registry, plugin, config)
|
||||
const callback = root.registry.resolve(plugin)
|
||||
if (callback === undefined) throw new Error('test invariants: companion is not a valid Cordis plugin')
|
||||
fibers.push(fiber)
|
||||
byCallback.set(callback, fiber)
|
||||
}
|
||||
|
||||
mount(InvariantService, { enabled: true })
|
||||
const testPath = expect.getState().testPath ?? ''
|
||||
const companionPaths = testInvariantCompanionPaths(testPath)
|
||||
for (const path of companionPaths) {
|
||||
const companion = testInvariantCompanions[path]
|
||||
if (companion === undefined) {
|
||||
throw new Error(`test invariants: selected companion vanished at ${path}`)
|
||||
}
|
||||
if (!companion.inject.includes('invariants')) {
|
||||
throw new Error(`test invariants: ${path} must inject the invariant service`)
|
||||
}
|
||||
mount(companion)
|
||||
}
|
||||
|
||||
const [serviceFiber, ...companionFibers] = fibers
|
||||
if (serviceFiber === undefined) throw new Error('test invariants: service fiber was not mounted')
|
||||
// A companion is initially PENDING on the invariant service, and Cordis
|
||||
// Fiber.await() only joins work already in flight. Wait for the service to
|
||||
// activate its dependants before joining their startup and failures.
|
||||
const ready = serviceFiber.await()
|
||||
.then(() => Promise.all(companionFibers.map(fiber => fiber.await())))
|
||||
.then(() => undefined)
|
||||
const host = { fibers, byCallback, ready }
|
||||
hosts.set(root, host)
|
||||
return host
|
||||
}
|
||||
|
||||
function joinInvariantStartup(fiber: PluginFiber, invariantReady: Promise<void>): PluginFiber {
|
||||
const readiness = fiber.await().then(async (loaded) => {
|
||||
await invariantReady
|
||||
return loaded
|
||||
})
|
||||
const joined = Object.create(fiber) as PluginFiber
|
||||
joined.then = readiness.then.bind(readiness)
|
||||
return joined
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
{
|
||||
"requiredSince": "2026-07-14",
|
||||
"required": [
|
||||
".agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md",
|
||||
".agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md",
|
||||
".agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md",
|
||||
".agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md",
|
||||
".agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md",
|
||||
".agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md",
|
||||
".agents/notes/implemented/process/2026-07-19-web-styling-system.md",
|
||||
"README.md",
|
||||
"docs/cookbook/adding-a-package.md",
|
||||
"docs/cookbook/adding-a-tool.md",
|
||||
@@ -11,8 +18,18 @@
|
||||
"docs/development.md",
|
||||
"docs/i18n/README.md",
|
||||
"docs/i18n/translation-rules.md",
|
||||
".agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md",
|
||||
".agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md",
|
||||
"docs/user/develop/basic/config.md",
|
||||
"docs/user/develop/basic/index.md",
|
||||
"docs/user/develop/basic/tool.md",
|
||||
"docs/user/develop/framework/events.md",
|
||||
"docs/user/develop/framework/index.md",
|
||||
"docs/user/develop/framework/service.md",
|
||||
"docs/user/develop/practice/index.md",
|
||||
"docs/user/develop/practice/llm-adapter.md",
|
||||
"docs/user/guide/config.md",
|
||||
"docs/user/guide/index.md",
|
||||
"docs/user/guide/quickstart.md",
|
||||
"docs/user/index.md",
|
||||
"python/README.md",
|
||||
"python/sdk-runtime/README.md",
|
||||
"python/sdk/README.md"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
102
scripts/verify-built-package-invariants.mjs
Normal file
102
scripts/verify-built-package-invariants.mjs
Normal file
@@ -0,0 +1,102 @@
|
||||
/** Verify every packed companion through its package self-reference under plain Node. */
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import {
|
||||
copyFileSync,
|
||||
globSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
} from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const loaderUrl = pathToFileURL(resolve(root, 'vendor/loader/lib/index.js')).href
|
||||
const failures = []
|
||||
const manifests = globSync('packages/*/*/package.json', { cwd: root }).sort()
|
||||
const packArgs = ['pack', '--dry-run', '--json', '--ignore-scripts']
|
||||
// Windows cannot spawn npm's .cmd shim directly; setup-node installs this JS
|
||||
// entrypoint beside node.exe, so the probe stays shell-free on every runner.
|
||||
const npmInvocation = process.platform === 'win32'
|
||||
? [process.execPath, [resolve(dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js'), ...packArgs]]
|
||||
: ['npm', packArgs]
|
||||
|
||||
for (const manifestPath of manifests) {
|
||||
const packageDir = dirname(resolve(root, manifestPath))
|
||||
const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8'))
|
||||
const packageName = manifest.name
|
||||
if (typeof packageName !== 'string' || packageName.length === 0) {
|
||||
failures.push(`${manifestPath}: missing package name`)
|
||||
continue
|
||||
}
|
||||
|
||||
const pack = spawnSync(npmInvocation[0], npmInvocation[1], {
|
||||
cwd: packageDir,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
if (pack.status !== 0) {
|
||||
const detail = pack.error?.message
|
||||
?? (pack.stderr.trim() || pack.stdout.trim() || `npm pack exited ${pack.status}`)
|
||||
failures.push(`${packageName}: ${detail}`)
|
||||
continue
|
||||
}
|
||||
|
||||
let files
|
||||
try {
|
||||
const result = JSON.parse(pack.stdout)
|
||||
files = result[0]?.files
|
||||
if (!Array.isArray(files)) throw new Error('npm pack returned no file inventory')
|
||||
} catch (error) {
|
||||
failures.push(`${packageName}: cannot parse npm pack inventory: ${String(error)}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Keep the packed view below its owning package so Node reaches the real
|
||||
// pnpm dependency links. Junctioning node_modules elsewhere breaks pnpm's
|
||||
// relative workspace links on Windows.
|
||||
const stagedPackageDir = mkdtempSync(resolve(packageDir, '.dsh-packed-invariant-'))
|
||||
try {
|
||||
for (const file of files) {
|
||||
if (typeof file.path !== 'string'
|
||||
|| (file.path !== 'package.json' && !file.path.startsWith('lib/'))) continue
|
||||
const target = resolve(stagedPackageDir, file.path)
|
||||
mkdirSync(dirname(target), { recursive: true })
|
||||
copyFileSync(resolve(packageDir, file.path), target)
|
||||
}
|
||||
|
||||
const probe = `
|
||||
const companion = await import(${JSON.stringify(`${packageName}/invariant`)});
|
||||
const { default: Loader } = await import(${JSON.stringify(loaderUrl)});
|
||||
if ('default' in companion) throw new Error('companion has a default export');
|
||||
const loader = Object.create(Loader.prototype);
|
||||
const unwrapped = loader.unwrapExports(companion);
|
||||
if (unwrapped !== companion) throw new Error('Loader collapsed the companion namespace');
|
||||
if (typeof unwrapped.name !== 'string') throw new Error('companion name is missing');
|
||||
if (!Array.isArray(unwrapped.inject) || !unwrapped.inject.includes('invariants')) {
|
||||
throw new Error('companion does not inject invariants');
|
||||
}
|
||||
if (typeof unwrapped.apply !== 'function') throw new Error('companion apply is missing');
|
||||
`
|
||||
const result = spawnSync(process.execPath, ['--input-type=module', '--eval', probe], {
|
||||
cwd: stagedPackageDir,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
const detail = result.error?.message
|
||||
?? (result.stderr.trim() || result.stdout.trim() || `node exited ${result.status}`)
|
||||
failures.push(`${packageName}: ${detail}`)
|
||||
}
|
||||
} finally {
|
||||
rmSync(stagedPackageDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('verify-built-package-invariants: packed companion failures:')
|
||||
for (const failure of failures) console.error(` ${failure}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`verify-built-package-invariants: ${manifests.length} packed companion(s) passed plain-Node Loader checks.`)
|
||||
102
scripts/verify-client-domain-graph.ts
Normal file
102
scripts/verify-client-domain-graph.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Enforce intra-package domain layering inside `packages/client/*\/src/client/`.
|
||||
* verify-module-graph covers package-level edges; this gate covers the
|
||||
* directory level the future package split will land on: domain directories
|
||||
* may import `contract/` and never each other, and only the assembly point
|
||||
* (`apply.ts` / `index.ts`) may import across domains.
|
||||
*
|
||||
* Layer model (lower may not import higher):
|
||||
* 0 contract/ shared contract surface (types + slot declarations)
|
||||
* 1 <domain>/ + service domain implementations (skeleton/, chat/, ...)
|
||||
* 2 apply.ts, index.ts assembly point and re-export shell
|
||||
*
|
||||
* Not yet wired into the gate sequence (loose-gate window); run directly:
|
||||
* pnpm exec tsx scripts/verify-client-domain-graph.ts
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const CLIENT_DIR = join(root, 'packages/client')
|
||||
|
||||
/** Directory names treated as the shared contract layer (importable by all). */
|
||||
const CONTRACT_DIRS = new Set(['contract'])
|
||||
/** Top-level client files allowed to import across domains (assembly layer). */
|
||||
const ASSEMBLY_FILES = new Set(['apply.ts', 'index.ts', 'index.tsx'])
|
||||
|
||||
interface Violation { file: string; imported: string; reason: string }
|
||||
|
||||
/** Recursively list .ts/.tsx files under dir (relative paths). */
|
||||
function listSources(dir: string, prefix = ''): string[] {
|
||||
const out: string[] = []
|
||||
for (const name of readdirSync(dir)) {
|
||||
const full = join(dir, name)
|
||||
const rel = prefix ? `${prefix}/${name}` : name
|
||||
if (statSync(full).isDirectory()) out.push(...listSources(full, rel))
|
||||
else if (/\.tsx?$/.test(name) && !/\.legacy\./.test(name)) out.push(rel)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** First path segment of a client-relative file, or '' for top-level files. */
|
||||
function domainOf(rel: string): string {
|
||||
const ix = rel.indexOf('/')
|
||||
return ix === -1 ? '' : rel.slice(0, ix)
|
||||
}
|
||||
|
||||
function checkPackage(pkgName: string, clientDir: string): Violation[] {
|
||||
const violations: Violation[] = []
|
||||
const files = listSources(clientDir)
|
||||
for (const rel of files) {
|
||||
const fromDomain = domainOf(rel)
|
||||
const isAssembly = fromDomain === '' && ASSEMBLY_FILES.has(rel)
|
||||
if (isAssembly) continue
|
||||
const source = readFileSync(join(clientDir, rel), 'utf8')
|
||||
for (const match of source.matchAll(/from\s+['"](\.[^'"]+)['"]/g)) {
|
||||
const spec = match[1]
|
||||
if (spec === undefined) continue
|
||||
// Resolve the relative specifier against the importing file's directory
|
||||
// to a client-dir-relative path.
|
||||
const fromDir = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : ''
|
||||
const parts = (fromDir ? fromDir.split('/') : [])
|
||||
for (const seg of spec.split('/')) {
|
||||
if (seg === '.') continue
|
||||
if (seg === '..') parts.pop()
|
||||
else parts.push(seg)
|
||||
}
|
||||
const target = parts.join('/')
|
||||
if (target.startsWith('..')) continue // out of client dir (package root) — package-level rules govern
|
||||
const toDomain = domainOf(target)
|
||||
if (toDomain === '' || CONTRACT_DIRS.has(toDomain)) continue // top-level shared file or contract layer
|
||||
if (fromDomain === toDomain) continue // inside one domain
|
||||
violations.push({
|
||||
file: `${pkgName}/src/client/${rel}`,
|
||||
imported: spec,
|
||||
reason: fromDomain === ''
|
||||
? `top-level non-assembly file imports domain "${toDomain}" (only apply/index may assemble)`
|
||||
: `domain "${fromDomain}" imports sibling domain "${toDomain}" (route shared surface through contract/)`,
|
||||
})
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
const violations: Violation[] = []
|
||||
for (const pkg of readdirSync(CLIENT_DIR)) {
|
||||
const clientDir = join(CLIENT_DIR, pkg, 'src/client')
|
||||
try {
|
||||
if (!statSync(clientDir).isDirectory()) continue
|
||||
} catch {
|
||||
// No client half in this package — nothing to layer-check.
|
||||
continue
|
||||
}
|
||||
violations.push(...checkPackage(pkg, clientDir))
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error(`verify-client-domain-graph: ${violations.length} violation(s):`)
|
||||
for (const v of violations) console.error(` ${v.file} -> ${v.imported}\n ${v.reason}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('verify-client-domain-graph: client domain layering clean.')
|
||||
@@ -152,15 +152,29 @@ function localPackageDirectories(): Map<string, string> {
|
||||
}
|
||||
|
||||
function rootProjectReferences(): Set<string> {
|
||||
const config = ts.readConfigFile(resolve(root, 'tsconfig.json'), path => ts.sys.readFile(path))
|
||||
if (config.error !== undefined) {
|
||||
throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n'))
|
||||
// Typecheck runs two sibling aggregates (root = host program,
|
||||
// tsconfig.client.json = client program; the two sides merge cordis Context
|
||||
// under the same keys, so one program cannot see both). Seed both and follow
|
||||
// any nested aggregate references to collect the covered leaf project set.
|
||||
const collected = new Set<string>()
|
||||
const queue = [resolve(root, 'tsconfig.json'), resolve(root, 'tsconfig.client.json')]
|
||||
const seen = new Set<string>()
|
||||
for (let file = queue.pop(); file !== undefined; file = queue.pop()) {
|
||||
if (seen.has(file)) continue
|
||||
seen.add(file)
|
||||
const config = ts.readConfigFile(file, path => ts.sys.readFile(path))
|
||||
if (config.error !== undefined) {
|
||||
throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n'))
|
||||
}
|
||||
const references = (config.config as { references?: Array<{ path?: unknown }> }).references ?? []
|
||||
for (const reference of references) {
|
||||
if (typeof reference.path !== 'string') continue
|
||||
const target = resolve(dirname(file), reference.path)
|
||||
if (target.endsWith('.json')) queue.push(target)
|
||||
else collected.add(target)
|
||||
}
|
||||
}
|
||||
const references = (config.config as { references?: Array<{ path?: unknown }> }).references ?? []
|
||||
return new Set(references.flatMap((reference) => {
|
||||
if (typeof reference.path !== 'string') return []
|
||||
return [resolve(root, reference.path)]
|
||||
}))
|
||||
return collected
|
||||
}
|
||||
|
||||
function packageNameFromSpecifier(specifier: string): string | undefined {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* Reject Markdown prose paragraphs spanning multiple physical lines. The GFM
|
||||
* AST distinguishes paragraphs—including those in lists and blockquotes—from
|
||||
* multiline structural nodes. The checker never rewrites; symlinked instruction
|
||||
* files are deduped. The owning convention is in `docs/AGENTS.md`.
|
||||
* files are deduped. VitePress frontmatter and custom-container delimiters are
|
||||
* masked before parsing. The owning convention is in `docs/AGENTS.md`.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
@@ -35,11 +36,23 @@ interface Violation {
|
||||
text: string
|
||||
}
|
||||
|
||||
function maskVitePressStructure(source: string): string {
|
||||
const lines = source.split('\n')
|
||||
if (lines[0] === '---') {
|
||||
const closing = lines.indexOf('---', 1)
|
||||
if (closing !== -1) {
|
||||
for (let index = 0; index <= closing; index++) lines[index] = ''
|
||||
}
|
||||
}
|
||||
return lines.map(line => line.trimStart().startsWith(':::') ? '' : line).join('\n')
|
||||
}
|
||||
|
||||
/** Find every hard-wrapped prose paragraph in one Markdown file via its AST. */
|
||||
function findViolations(absPath: string): Violation[] {
|
||||
const file = relative(root, absPath)
|
||||
const source = readFileSync(absPath, 'utf8')
|
||||
const tree = parseMarkdown(source)
|
||||
const parsedSource = maskVitePressStructure(source)
|
||||
const tree = parseMarkdown(parsedSource)
|
||||
const out: Violation[] = []
|
||||
|
||||
visitMarkdown(tree, (node: Nodes): boolean | void => {
|
||||
|
||||
@@ -79,7 +79,10 @@ Object.defineProperty(globalThis, 'window', { value: window })
|
||||
Object.defineProperty(globalThis, 'document', { value: window.document })
|
||||
Object.defineProperty(globalThis, 'navigator', { value: window.navigator })
|
||||
const mermaid = (await import('mermaid')).default
|
||||
mermaid.initialize({ startOnLoad: false })
|
||||
// maxEdges: mermaid's default 500-edge render guard; the module graph grows
|
||||
// with every package edge and crossed it legitimately. Raise the guard here
|
||||
// (a secure config settable only via initialize) rather than trimming edges.
|
||||
mermaid.initialize({ startOnLoad: false, maxEdges: 1000 })
|
||||
for (const block of blocks) {
|
||||
try {
|
||||
await mermaid.parse(block.source, { suppressErrors: false })
|
||||
|
||||
21
scripts/verify-package-invariants.ts
Normal file
21
scripts/verify-package-invariants.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/** Verify package-owned invariant source and publication contracts. */
|
||||
|
||||
import { resolve } from 'node:path'
|
||||
import {
|
||||
collectPackageInvariantViolations,
|
||||
formatPackageInvariantViolation,
|
||||
packageInvariantOwners,
|
||||
} from './package-invariants.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const violations = collectPackageInvariantViolations(root)
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error('verify-package-invariants: violations found:')
|
||||
for (const violation of violations) {
|
||||
console.error(` ${formatPackageInvariantViolation(root, violation)}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`verify-package-invariants: ${packageInvariantOwners(root).length} hand-owned package companion(s) conform.`)
|
||||
@@ -45,13 +45,30 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/bash/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' },
|
||||
'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' },
|
||||
'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' },
|
||||
'packages/client/ui-slots': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-primitives': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/web-react': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/connection': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/runtime': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-theme': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/i18n': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' },
|
||||
'packages/fs/fs': { kind: 'indirect', reason: 'The service interface delegates model rendering to dsh-tool-fs.' },
|
||||
'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
|
||||
'packages/fs/fs-sandbox': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
|
||||
'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' },
|
||||
'packages/host/apiproxy': { kind: 'none', reason: 'The wire contract and fetch carriers move already-composed messages and register no model surface.' },
|
||||
'packages/host/runtime': { kind: 'indirect', reason: 'The assembly mounts model-facing plugins and injects provider/model defaults into agents.' },
|
||||
'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers no model surface.' },
|
||||
'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' },
|
||||
'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' },
|
||||
'packages/lsp/lsp': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-lsp.' },
|
||||
'packages/lsp/lsp-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-lsp.' },
|
||||
'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' },
|
||||
'packages/sandbox/sandbox-policy': { kind: 'indirect', reason: 'The policy service holds the mode dsh-tool-bash and dsh-tool-fs render in their denial markers.' },
|
||||
'packages/sdk/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' },
|
||||
@@ -76,7 +93,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/examples/jsonrpc-demo': { kind: 'indirect', reason: 'Only the externally configured plugin tree contributes model context.' },
|
||||
'packages/ui/permission': { kind: 'indirect', reason: 'The service writes mechanism events rendered by dsh-user-approval and dsh-tool-bash.' },
|
||||
'packages/ui/user-interaction': { kind: 'indirect', reason: 'Model-facing consumers render provider answers and seam errors.' },
|
||||
'packages/util/home': { kind: 'indirect', reason: 'Only dsh-tool-bash exposes the resolved home to model commands.' },
|
||||
'packages/util/timeout': { kind: 'indirect', reason: 'Only timeout consumers render timeout outcomes.' },
|
||||
'packages/util/retention': { kind: 'indirect', reason: 'Only retention consumers render retained content and omission metadata.' },
|
||||
'packages/web/web': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-web.' },
|
||||
|
||||
@@ -14,7 +14,7 @@ import ts from 'typescript'
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
/** Scan doc-typecheck's full Markdown scope so unmanifested blocks also fail. */
|
||||
const MARKDOWN_GLOBS = ['README.md', '.agents/notes/**/*.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'website/zh-CN/**/*.md']
|
||||
const MARKDOWN_GLOBS = ['README.md', '.agents/notes/**/*.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md']
|
||||
|
||||
/** One manifest entry: a source-equivalence block and its source symbol. */
|
||||
interface ManifestEntry {
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
/**
|
||||
* Doc-sync gate: verify the fenced ```yaml examples in the website against
|
||||
* the loader and the workspace truth. A `cordis.yml` example that names a
|
||||
* plugin that does not exist, or passes a config key the plugin never
|
||||
* declared, is worse than no example — it fails silently for the reader.
|
||||
*
|
||||
* Scope: `website/zh-CN/**/*.md`, EXCLUDING `website/zh-CN/api/**` (the api
|
||||
* pages are generator-owned — their yaml examples are verified at generation
|
||||
* time by a later stream, not re-checked here). Blocks opt out with
|
||||
* ` ```yaml ignore-check ` (same philosophy as doc-typecheck's opt-out: the
|
||||
* count is reported, an unchecked block is a visible decision, not a silent
|
||||
* hole — placeholder plugin names in tutorials are the legitimate case).
|
||||
*
|
||||
* Each checked block is parsed with the loader's REAL schema —
|
||||
* `JSON_SCHEMA` extended with the `!!js` scalar type exactly as
|
||||
* vendor/include/src/index.ts declares it — so `!!js process.env.X` parses
|
||||
* here iff it parses at runtime. Then:
|
||||
*
|
||||
* - Root is an ARRAY → a cordis.yml entry list. Every item must be a mapping
|
||||
* with a string `name` and only the keys `EntryOptions` declares
|
||||
* (vendor/loader/src/config/entry.ts plus the isolate.ts merge:
|
||||
* id, name, config, group, disabled, inject, intercept, isolate).
|
||||
* - `./` / `../` names are illustrative local plugins — existence is not
|
||||
* checkable, skip. `group:*` names are loader built-ins; their `config`
|
||||
* is itself an entry list and is recursed into.
|
||||
* - Any other name must be a real workspace package (`packages/*/*` and
|
||||
* `vendor/*` package.json names).
|
||||
* - For `@deepseek-ai/dsh-*` names the config-catalog generator is the
|
||||
* truth: kind `config` → the yaml `config`'s top-level keys must be
|
||||
* properties of the declared config type (member names of the first
|
||||
* catalog paste ∪ top-level segments of the runtime schema keys);
|
||||
* config-free kinds → a non-empty `config` mapping is a violation;
|
||||
* seam/library kinds → name existence only (loading one directly is
|
||||
* dubious, but that is a docs-prose concern, not this gate's).
|
||||
* - Root is a MAPPING or scalar → a fragment (e.g. a bare `config:` excerpt):
|
||||
* syntax check only.
|
||||
*
|
||||
* This is a checker, not a fixer: it reports `file:line message` and exits 1.
|
||||
*
|
||||
* Run: `tsx scripts/verify-website-yaml.ts`.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import * as yaml from 'js-yaml'
|
||||
import ts from 'typescript'
|
||||
import { collectConfigCatalog, type CatalogEntry } from './gen-config-catalog.ts'
|
||||
import { extractFences } from './md-fences.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
/** Mirror of the loader's yaml schema (vendor/include/src/index.ts): the
|
||||
* `!!js` tag parses to an expression wrapper, everything else is JSON. */
|
||||
const JsExpr = new yaml.Type('tag:yaml.org,2002:js', {
|
||||
kind: 'scalar',
|
||||
resolve: data => typeof data === 'string',
|
||||
construct: (data: string) => ({ __jsExpr: data }),
|
||||
})
|
||||
const schema = yaml.JSON_SCHEMA.extend(JsExpr)
|
||||
|
||||
/** The exact key set an entry mapping may carry: `EntryOptions` in
|
||||
* vendor/loader/src/config/entry.ts plus the isolate.ts interface merge. */
|
||||
const ENTRY_KEYS = ['id', 'name', 'config', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const
|
||||
|
||||
/** One `file:line message` finding. */
|
||||
interface Violation {
|
||||
file: string
|
||||
/** 1-based line of the block's opening fence. */
|
||||
line: number
|
||||
message: string
|
||||
}
|
||||
|
||||
/** One extracted ```yaml block. */
|
||||
interface Block {
|
||||
file: string
|
||||
/** 1-based line of the opening fence. */
|
||||
line: number
|
||||
kind: 'check' | 'ignore'
|
||||
code: string
|
||||
}
|
||||
|
||||
/** Extract every ```yaml / ```yaml ignore-check block from one Markdown file. */
|
||||
function extractBlocks(file: string): Block[] {
|
||||
return extractFences(resolve(root, file), info =>
|
||||
info === 'yaml' ? 'check' : info === 'yaml ignore-check' ? 'ignore' : null)
|
||||
.map(f => ({ file, line: f.line, kind: f.kind, code: f.code }))
|
||||
}
|
||||
|
||||
/** Every workspace package name: `packages/<group>/<pkg>` and `vendor/<pkg>`. */
|
||||
function knownPackages(): Set<string> {
|
||||
const names = new Set<string>()
|
||||
for (const pattern of ['packages/*/*/package.json', 'vendor/*/package.json']) {
|
||||
for (const match of globSync(pattern, { cwd: root })) {
|
||||
const pkg: unknown = JSON.parse(readFileSync(resolve(root, match), 'utf8'))
|
||||
if (typeof pkg === 'object' && pkg !== null && 'name' in pkg && typeof pkg.name === 'string') {
|
||||
names.add(pkg.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
/** The catalog, built once on first `@deepseek-ai/dsh-*` name, keyed by pkg. */
|
||||
let catalogByPkg: Map<string, CatalogEntry> | null = null
|
||||
function catalogFor(pkg: string): CatalogEntry | undefined {
|
||||
catalogByPkg ??= new Map(collectConfigCatalog().map(e => [e.pkg, e]))
|
||||
return catalogByPkg.get(pkg)
|
||||
}
|
||||
|
||||
/** Top-level property names of the first catalog paste (the verbatim config
|
||||
* type declaration), parsed as source text. */
|
||||
function pasteKeys(paste: string): Set<string> {
|
||||
const sf = ts.createSourceFile('paste.ts', paste, ts.ScriptTarget.Latest, true)
|
||||
const keys = new Set<string>()
|
||||
const addMembers = (members: ts.NodeArray<ts.TypeElement>): void => {
|
||||
for (const m of members) {
|
||||
if (ts.isPropertySignature(m) || ts.isMethodSignature(m)) {
|
||||
const name = m.name
|
||||
keys.add(ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : name.getText(sf))
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const stmt of sf.statements) {
|
||||
if (ts.isInterfaceDeclaration(stmt)) addMembers(stmt.members)
|
||||
else if (ts.isTypeAliasDeclaration(stmt) && ts.isTypeLiteralNode(stmt.type)) addMembers(stmt.type.members)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
/** The allowed top-level config keys of a kind-`config` catalog entry: the
|
||||
* first paste's member names ∪ the schema keys' top-level segments
|
||||
* (`agents[].id` → `agents`). Cached per entry. */
|
||||
const allowedKeysCache = new Map<string, Set<string>>()
|
||||
function allowedConfigKeys(entry: CatalogEntry): Set<string> {
|
||||
const cached = allowedKeysCache.get(entry.pkg)
|
||||
if (cached) return cached
|
||||
const keys = pasteKeys(entry.pastes?.[0]?.text ?? '')
|
||||
for (const path of entry.schemaKeys ?? []) {
|
||||
const top = path.split('.')[0]?.replace(/\[\]$/, '')
|
||||
if (top) keys.add(top)
|
||||
}
|
||||
allowedKeysCache.set(entry.pkg, keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
/** A parsed yaml mapping (arrays and `!!js` wrappers excluded). */
|
||||
function asMapping(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null
|
||||
if ('__jsExpr' in value) return null
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Check one cordis.yml entry list (recursing into `group:` sub-lists). */
|
||||
function checkEntryList(
|
||||
items: unknown[],
|
||||
known: Set<string>,
|
||||
block: Block,
|
||||
violations: Violation[],
|
||||
): void {
|
||||
const flag = (message: string): void => {
|
||||
violations.push({ file: block.file, line: block.line, message })
|
||||
}
|
||||
items.forEach((item, index) => {
|
||||
const at = `entry ${index + 1}`
|
||||
const entry = asMapping(item)
|
||||
if (!entry) {
|
||||
flag(`${at}: not a mapping`)
|
||||
return
|
||||
}
|
||||
const name = entry['name']
|
||||
if (typeof name !== 'string') {
|
||||
flag(`${at}: missing string \`name\``)
|
||||
return
|
||||
}
|
||||
for (const key of Object.keys(entry)) {
|
||||
if (!(ENTRY_KEYS as readonly string[]).includes(key)) {
|
||||
flag(`${at} (${name}): unknown entry key \`${key}\` (EntryOptions allows: ${[...ENTRY_KEYS].join(', ')})`)
|
||||
}
|
||||
}
|
||||
// Illustrative local plugin — nothing on disk to check against.
|
||||
if (name.startsWith('./') || name.startsWith('../')) return
|
||||
// A `group:`-style pseudo-name is NOT loadable: tree.import() only
|
||||
// special-cases the `cordis:` prefix, and nothing in this repo registers
|
||||
// loader builtins — reject it and point at the real group plugin.
|
||||
if (name.startsWith('group:')) {
|
||||
flag(`${at}: \`${name}\` is not loadable (no loader builtin is registered); use \`@cordisjs/plugin-group\` with \`group: true\``)
|
||||
return
|
||||
}
|
||||
// The vendored group plugin: its config is a nested entry list.
|
||||
if (name === '@cordisjs/plugin-group') {
|
||||
if (Array.isArray(entry['config'])) checkEntryList(entry['config'], known, block, violations)
|
||||
return
|
||||
}
|
||||
if (!known.has(name)) {
|
||||
flag(`${at}: unknown plugin \`${name}\` (not a workspace package)`)
|
||||
return
|
||||
}
|
||||
if (!name.startsWith('@deepseek-ai/dsh-')) return
|
||||
const catalog = catalogFor(name)
|
||||
if (!catalog) return
|
||||
const config = asMapping(entry['config'])
|
||||
if (catalog.kind === 'config') {
|
||||
if (!config) return
|
||||
const allowed = allowedConfigKeys(catalog)
|
||||
for (const key of Object.keys(config)) {
|
||||
if (!allowed.has(key)) {
|
||||
flag(`${at}: \`${name}\` has no config key \`${key}\` (known keys: ${[...allowed].sort().join(', ')})`)
|
||||
}
|
||||
}
|
||||
} else if (catalog.kind === 'no-config') {
|
||||
if (config && Object.keys(config).length > 0) {
|
||||
flag(`${at}: \`${name}\` declares no config, but the example passes one`)
|
||||
}
|
||||
}
|
||||
// seam / library: loading one directly is dubious, but that is a prose
|
||||
// concern — this gate only vouches for name existence.
|
||||
})
|
||||
}
|
||||
|
||||
const files = globSync('website/zh-CN/**/*.md', { cwd: root })
|
||||
.filter(f => !f.startsWith('website/zh-CN/api/'))
|
||||
.sort()
|
||||
|
||||
const violations: Violation[] = []
|
||||
const known = knownPackages()
|
||||
let entryLists = 0
|
||||
let fragments = 0
|
||||
let ignored = 0
|
||||
let scanned = 0
|
||||
|
||||
for (const file of files) {
|
||||
for (const block of extractBlocks(file)) {
|
||||
scanned++
|
||||
if (block.kind === 'ignore') {
|
||||
ignored++
|
||||
continue
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = yaml.load(block.code, { schema })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message.split('\n')[0] ?? 'parse error' : String(error)
|
||||
violations.push({ file: block.file, line: block.line, message: `yaml parse error: ${message}` })
|
||||
continue
|
||||
}
|
||||
if (Array.isArray(parsed)) {
|
||||
entryLists++
|
||||
checkEntryList(parsed, known, block, violations)
|
||||
} else {
|
||||
// Mapping or scalar root: a fragment (e.g. a bare `config:` excerpt) —
|
||||
// syntax is all there is to check.
|
||||
fragments++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length === 0) {
|
||||
console.log(
|
||||
`verify-website-yaml: ${scanned} yaml block(s) in ${files.length} file(s): `
|
||||
+ `${entryLists} entry list(s) + ${fragments} fragment(s) checked, ${ignored} ignore-check skipped.`,
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.error('verify-website-yaml: invalid yaml examples found:')
|
||||
for (const v of violations) {
|
||||
console.error(` ${v.file}:${v.line} ${v.message}`)
|
||||
}
|
||||
process.exit(1)
|
||||
Reference in New Issue
Block a user