mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge refreshed docs/i18n-batch-core into docs/i18n-batch-cds-postmortem
# Conflicts: # .agents/notes/README.i18n.yaml # .agents/notes/README.zh.md # docs/core-data-structures/bash.md # docs/core-data-structures/code-runtime.md # docs/core-data-structures/compaction.md # docs/core-data-structures/scope.md # docs/core-data-structures/session-query.md # docs/core-data-structures/user-interaction.md # docs/core-data-structures/web.md # docs/rfc/README.md # scripts/translation-pairing.manifest.json # scripts/type-equiv.manifest.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.
|
||||
78
scripts/agent-note-tree.ts
Normal file
78
scripts/agent-note-tree.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Shared structural source of truth for the Agent Note tree. Lifecycle and class
|
||||
* sets are closed under `.agents/notes/README.md`; importing this module is pure.
|
||||
*/
|
||||
|
||||
import { globSync, readdirSync } from 'node:fs'
|
||||
import { resolve, sep } from 'node:path'
|
||||
|
||||
export const agentNoteRoot = resolve(import.meta.dirname, '../.agents/notes')
|
||||
|
||||
/** The closed set of Agent Note lifecycles (top-level folders under .agents/notes/). */
|
||||
const LIFECYCLES = ['proposed', 'implemented', 'rejected'] as const
|
||||
|
||||
/**
|
||||
* The closed set of Agent Note classes (nested folder under each lifecycle). Adding a
|
||||
* class is a deliberate act: extend this list AND the README's Classification
|
||||
* section. The gate rejects any folder not listed here.
|
||||
*/
|
||||
const CLASSES = ['feature', 'bug-fix', 'simplification', 'architecture', 'process', 'testing'] as const
|
||||
|
||||
/** Non-Agent Note Markdown allowed to sit directly at a lifecycle root. */
|
||||
const ROOT_ALLOWLIST = new Set(['AGENTS.md', 'CLAUDE.md'])
|
||||
|
||||
/** One Agent Note file, as discovered by the walker. */
|
||||
export interface AgentNote {
|
||||
lifecycle: string
|
||||
/** Path relative to .agents/notes. */
|
||||
rel: string
|
||||
/** `yyyy-mm-dd` from the filename. */
|
||||
date: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the Agent Note tree, enforcing the structure rules. Returns every valid Agent Note
|
||||
* plus one error string per violation (unknown lifecycle or class folder, bad
|
||||
* depth, or bad filename). Callers treat a non-empty error list as fatal.
|
||||
*/
|
||||
export function walkAgentNoteTree(): { notes: AgentNote[]; errors: string[] } {
|
||||
const notes: AgentNote[] = []
|
||||
const errors: string[] = []
|
||||
// The lifecycle set is closed too: any directory under .agents/notes/ that is not
|
||||
// a known lifecycle would otherwise hold Agent Notes invisible to the walk below.
|
||||
for (const entry of readdirSync(agentNoteRoot, { withFileTypes: true })) {
|
||||
if (entry.name === 'INDEX.md') {
|
||||
errors.push('structure: INDEX.md — centralized Agent Note indexes are forbidden; browse the lifecycle/class tree or search the repository')
|
||||
continue
|
||||
}
|
||||
if (entry.isDirectory() && !(LIFECYCLES as readonly string[]).includes(entry.name)) {
|
||||
errors.push(`structure: ${entry.name}/ — unknown lifecycle folder (allowed: ${LIFECYCLES.join(', ')})`)
|
||||
}
|
||||
}
|
||||
for (const lifecycle of LIFECYCLES) {
|
||||
for (const match of globSync(`${lifecycle}/**/*.md`, { cwd: agentNoteRoot }).map(path => path.split(sep).join('/')).sort()) {
|
||||
const segs = match.split('/')
|
||||
// Allowlisted file directly at the lifecycle root (e.g. implemented/AGENTS.md).
|
||||
if (segs.length === 2 && ROOT_ALLOWLIST.has(segs[1] ?? '')) continue
|
||||
// A Chinese counterpart (foo.zh.md, docs/i18n/README.md) is the SAME Agent Note,
|
||||
// indexed via its English filename; the pairing gate owns its consistency.
|
||||
if (match.endsWith('.zh.md')) continue
|
||||
const cls = segs[1]
|
||||
const base = segs[2]
|
||||
if (segs.length !== 3 || cls === undefined || base === undefined) {
|
||||
errors.push(`structure: ${match} — expected {lifecycle}/{class}/file.md (got depth ${segs.length})`)
|
||||
continue
|
||||
}
|
||||
if (!(CLASSES as readonly string[]).includes(cls)) {
|
||||
errors.push(`structure: ${match} — unknown class folder "${cls}" (allowed: ${CLASSES.join(', ')})`)
|
||||
continue
|
||||
}
|
||||
if (!/^\d{4}-\d{2}-\d{2}-.+\.md$/.test(base)) {
|
||||
errors.push(`structure: ${match} — filename must be yyyy-mm-dd-topic.md`)
|
||||
continue
|
||||
}
|
||||
notes.push({ lifecycle, rel: match, date: base.slice(0, 10) })
|
||||
}
|
||||
}
|
||||
return { notes, errors }
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Build the SDK runtime executables and Python node carrier. The fixed
|
||||
* `@yao-pkg/pkg --sea` route, deploy flags, and artifact layout are owned by
|
||||
* docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md.
|
||||
* .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md.
|
||||
* The staged closure is symlink-free, and whole-tree assets cover Cordis's
|
||||
* runtime imports that pkg cannot discover statically.
|
||||
*/
|
||||
@@ -69,7 +69,7 @@ class Target {
|
||||
readonly nodeRange: string,
|
||||
/**
|
||||
* pkg platform tag. Windows is a documented non-goal
|
||||
* (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md).
|
||||
* (.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md).
|
||||
*/
|
||||
readonly platform: Platform,
|
||||
/** pkg CPU tag. */
|
||||
@@ -190,7 +190,7 @@ class BuildCli {
|
||||
' --dry-run print every command and config patch without executing.',
|
||||
' --help print this help.',
|
||||
'',
|
||||
`Build route: ${PKG_SPEC} --sea; see docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md.`,
|
||||
`Build route: ${PKG_SPEC} --sea; see .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md.`,
|
||||
`Stages the node carrier in ${PYTHON_RUNTIME_DIR}/${PYTHON_NODE_SUBDIR} and writes executables to ${OUT_DIR}/.`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
26
scripts/check-expected-filenames.sh
Executable file
26
scripts/check-expected-filenames.sh
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Vendored upstream paths follow vendor/README.md instead of repository naming policy.
|
||||
root=$(git rev-parse --show-toplevel)
|
||||
candidate_file=$(mktemp)
|
||||
trap 'unlink "$candidate_file"' EXIT
|
||||
git -C "$root" ls-files -z -- \
|
||||
':(icase,glob)*golden*' \
|
||||
':(icase,glob)**/*golden*' \
|
||||
':(exclude,glob)vendor/**' > "$candidate_file"
|
||||
|
||||
violations=()
|
||||
while IFS= read -r -d '' path; do
|
||||
violations+=("$path")
|
||||
done < "$candidate_file"
|
||||
|
||||
if (( ${#violations[@]} == 0 )); then
|
||||
echo 'check-expected-filenames: no tracked non-vendor filename contains "golden".'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo 'check-expected-filenames: tracked non-vendor filenames must not contain "golden":' >&2
|
||||
printf ' %s\n' "${violations[@]}" >&2
|
||||
echo 'Rename each file with an accurate term such as "expected".' >&2
|
||||
exit 1
|
||||
@@ -40,10 +40,12 @@ interface PackageManifest {
|
||||
bin?: string | Record<string, string>
|
||||
exports?: Record<
|
||||
string,
|
||||
| string
|
||||
| {
|
||||
types?: string
|
||||
default?: string
|
||||
}
|
||||
| null
|
||||
| undefined
|
||||
>
|
||||
files?: string[]
|
||||
@@ -93,40 +95,61 @@ 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': [
|
||||
'lib/dev/tsdown-config.js',
|
||||
'lib/local-plugin-loader-hooks.js',
|
||||
'lib/assets',
|
||||
],
|
||||
}
|
||||
|
||||
function sameStringList(actual: readonly string[] | undefined, expected: readonly string[]): boolean {
|
||||
return !!actual && actual.length === expected.length && actual.every((value, index) => value === expected[index])
|
||||
}
|
||||
|
||||
function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] {
|
||||
if (manifest.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
|
||||
const extras = manifest.name ? packageFileExtras[manifest.name] ?? [] : []
|
||||
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[] {
|
||||
@@ -162,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)]))
|
||||
}
|
||||
91
scripts/cordis-walk.ts
Normal file
91
scripts/cordis-walk.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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'
|
||||
import { parseJsDoc, pointer, rawJsDoc } from './jsdoc.ts'
|
||||
|
||||
/** The body of the cordis module merge in `sf`: `declare module 'cordis'`
|
||||
* (harness packages) or `declare module './context.ts'` (vendor core), or
|
||||
* null when the file has neither. */
|
||||
export function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
|
||||
for (const stmt of sf.statements) {
|
||||
if (!ts.isModuleDeclaration(stmt) || !ts.isStringLiteral(stmt.name)) continue
|
||||
if (stmt.name.text !== 'cordis' && stmt.name.text !== './context.ts') continue
|
||||
if (stmt.body && ts.isModuleBlock(stmt.body)) return stmt.body
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Every `interface Events` method member of a cordis module merge, with the
|
||||
* event name resolved from its (possibly string-literal) property name. */
|
||||
export function eventMembers(body: ts.ModuleBlock, sf: ts.SourceFile): { name: string; member: ts.MethodSignature }[] {
|
||||
const out: { name: string; member: ts.MethodSignature }[] = []
|
||||
for (const stmt of body.statements) {
|
||||
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Events') continue
|
||||
for (const member of stmt.members) {
|
||||
if (!ts.isMethodSignature(member)) continue
|
||||
const name = ts.isStringLiteral(member.name) ? member.name.text : member.name.getText(sf)
|
||||
out.push({ name, member })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The `ctx.<key> → type name` map declared by a merge's `interface Context`. */
|
||||
function contextKeyMap(body: ts.ModuleBlock, sf: ts.SourceFile): Map<string, string> {
|
||||
const keyToType = new Map<string, string>()
|
||||
for (const stmt of body.statements) {
|
||||
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Context') continue
|
||||
for (const member of stmt.members) {
|
||||
if (!ts.isPropertySignature(member) || !member.type) continue
|
||||
keyToType.set(member.name.getText(sf), member.type.getText(sf))
|
||||
}
|
||||
}
|
||||
return keyToType
|
||||
}
|
||||
|
||||
/** One `ctx.<key>` service class resolved from a Context merge. */
|
||||
export interface ServiceClass {
|
||||
key: string
|
||||
type: string
|
||||
cls: ts.ClassDeclaration
|
||||
abstract: boolean
|
||||
/** Class-level JSDoc prose (empty string when missing — also reported). */
|
||||
doc: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve each `ctx.<key>` of a merge to the service class declared in the
|
||||
* same file. A key whose type is not a class here (a Pick-mixin member, e.g.
|
||||
* timer helpers) is skipped. A class without JSDoc prose is reported into
|
||||
* `violations` (named `where` by the caller's gate).
|
||||
*
|
||||
* @param body — the cordis module merge body.
|
||||
* @param sf — the source file containing the merge.
|
||||
* @param rel — repo-relative path of `sf`, for violation pointers.
|
||||
* @param violations — sink for JSDoc-completeness violations.
|
||||
* @returns the resolved service classes, in Context-declaration order.
|
||||
*/
|
||||
export function serviceClasses(
|
||||
body: ts.ModuleBlock,
|
||||
sf: ts.SourceFile,
|
||||
rel: string,
|
||||
violations: string[],
|
||||
): ServiceClass[] {
|
||||
const text = sf.getFullText()
|
||||
const out: ServiceClass[] = []
|
||||
for (const [key, type] of contextKeyMap(body, sf)) {
|
||||
const cls = sf.statements.find(
|
||||
(s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === type,
|
||||
)
|
||||
if (!cls) continue // a Pick-mixin member, not a class here
|
||||
const abstract = cls.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false
|
||||
const doc = parseJsDoc(rawJsDoc(text, cls)).doc
|
||||
if (!doc) violations.push(`service ctx.${key} (${pointer(rel, sf, cls)}): class ${type} has no JSDoc.`)
|
||||
out.push({ key, type, cls, abstract, doc })
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,22 +1,20 @@
|
||||
/**
|
||||
* Boot the REPL 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.
|
||||
* Both require a DeepSeek API key; unsupported arguments fail with usage.
|
||||
* 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/coding-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|acp]')
|
||||
console.error('usage: pnpm run demo:code-mode [tui|acp]')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"AGENTS.md": 1370,
|
||||
"docs/AGENTS.md": 1100,
|
||||
"docs/architecture.md": 1790,
|
||||
"AGENTS.md": 1600,
|
||||
"docs/AGENTS.md": 1150,
|
||||
"docs/architecture.md": 1800,
|
||||
"docs/cordis-primer.md": 600,
|
||||
"docs/defensive-patterns.md": 550,
|
||||
"docs/testing.md": 800,
|
||||
"examples/AGENTS.md": 200,
|
||||
"packages/AGENTS.md": 290,
|
||||
"docs/testing.md": 960,
|
||||
"examples/AGENTS.md": 310,
|
||||
"packages/AGENTS.md": 650,
|
||||
"packages/README.md": 760
|
||||
}
|
||||
|
||||
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}`)
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
/**
|
||||
* Typecheck Markdown `ts` fences against workspace sources. `ignore-check`
|
||||
* fences are reported as opt-outs; generated catalog fragments and
|
||||
* `type-equiv` blocks are skipped here because their owning gates verify them.
|
||||
* Typecheck Markdown `ts` fences against the workspace API. `ignore-check` fences are reported as
|
||||
* opt-outs; generated catalog fragments and source-equivalence blocks are skipped here because their
|
||||
* owning gates verify them. A build-coordinated mode consumes existing declarations without emit.
|
||||
*/
|
||||
|
||||
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, '..')
|
||||
|
||||
@@ -27,60 +29,119 @@ interface Block {
|
||||
code: string
|
||||
}
|
||||
|
||||
/** The info-string → kind table this gate tracks. */
|
||||
const KIND_BY_INFO: Record<string, BlockKind> = {
|
||||
'ts': 'check',
|
||||
'ts ignore-check': 'ignore',
|
||||
'ts type-equiv': 'type-equiv',
|
||||
'ts public-api': 'type-equiv',
|
||||
'ts cordis-catalog': 'cordis-catalog',
|
||||
'ts persistence-catalog': 'persistence-catalog',
|
||||
'ts config-catalog': 'config-catalog',
|
||||
}
|
||||
|
||||
/** Extract every recognized TypeScript fence from one Markdown file. */
|
||||
function extractBlocks(absPath: string): Block[] {
|
||||
const text = readFileSync(absPath, 'utf8')
|
||||
const lines = text.split('\n')
|
||||
const file = relative(root, absPath)
|
||||
const blocks: Block[] = []
|
||||
let open: { line: number; kind: BlockKind; body: string[] } | null = null
|
||||
return extractFences(absPath, info => KIND_BY_INFO[info] ?? null)
|
||||
.map(f => ({ file, line: f.line, kind: f.kind, code: f.code }))
|
||||
}
|
||||
|
||||
lines.forEach((raw, i) => {
|
||||
const fence = /^```(\s*)(\S.*)?$/.exec(raw)
|
||||
if (!fence) {
|
||||
if (open) open.body.push(raw)
|
||||
return
|
||||
}
|
||||
if (open) {
|
||||
// closing fence
|
||||
blocks.push({ file, line: open.line, kind: open.kind, code: open.body.join('\n') })
|
||||
open = null
|
||||
return
|
||||
}
|
||||
// Ignore non-TypeScript fences.
|
||||
const info = (fence[2] ?? '').trim()
|
||||
const kind: BlockKind | null =
|
||||
info === 'ts' ? 'check'
|
||||
: info === 'ts ignore-check' ? 'ignore'
|
||||
: info === 'ts type-equiv' ? 'type-equiv'
|
||||
: info === 'ts cordis-catalog' ? 'cordis-catalog'
|
||||
: info === 'ts persistence-catalog' ? 'persistence-catalog'
|
||||
: info === 'ts config-catalog' ? 'config-catalog'
|
||||
: null
|
||||
if (kind) open = { line: i + 1, kind, body: [] }
|
||||
const configHost: ts.ParseConfigFileHost = {
|
||||
...ts.sys,
|
||||
getCurrentDirectory: () => root,
|
||||
onUnRecoverableConfigFileDiagnostic(diagnostic) {
|
||||
throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))
|
||||
},
|
||||
}
|
||||
|
||||
/** Load root settings and redirect workspace aliases to declarations from the coordinated build. */
|
||||
function builtTypeCompilerOptions(): ts.CompilerOptions {
|
||||
const configPath = join(root, 'tsconfig.json')
|
||||
const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, configHost)
|
||||
if (!parsed) throw new Error(`doc-typecheck: cannot parse ${configPath}`)
|
||||
if (parsed.errors.length > 0) {
|
||||
throw new Error(parsed.errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n'))
|
||||
}
|
||||
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(builtDeclarationPath),
|
||||
]))
|
||||
const options: ts.CompilerOptions = {
|
||||
...parsed.options,
|
||||
paths,
|
||||
noEmit: true,
|
||||
composite: false,
|
||||
incremental: false,
|
||||
declaration: false,
|
||||
declarationMap: false,
|
||||
sourceMap: false,
|
||||
noUnusedLocals: false,
|
||||
noUnusedParameters: false,
|
||||
}
|
||||
delete options.tsBuildInfoFile
|
||||
return options
|
||||
}
|
||||
|
||||
/** Compile Markdown blocks as virtual files against declarations from the coordinated build. */
|
||||
function compileBlocksAgainstBuiltTypes(blocks: Block[]): readonly ts.Diagnostic[] {
|
||||
const options = builtTypeCompilerOptions()
|
||||
const sources = new Map<string, string>()
|
||||
for (const [index, block] of blocks.entries()) {
|
||||
const fileName = resolve(root, '.doc-typecheck', `block-${index}.ts`)
|
||||
sources.set(fileName, block.code.endsWith('\n') ? block.code : `${block.code}\n`)
|
||||
}
|
||||
|
||||
const baseHost = ts.createCompilerHost(options, true)
|
||||
const host: ts.CompilerHost = {
|
||||
...baseHost,
|
||||
fileExists(fileName) {
|
||||
return sources.has(resolve(fileName)) || baseHost.fileExists(fileName)
|
||||
},
|
||||
readFile(fileName) {
|
||||
return sources.get(resolve(fileName)) ?? baseHost.readFile(fileName)
|
||||
},
|
||||
getSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile) {
|
||||
const source = sources.get(resolve(fileName))
|
||||
if (source !== undefined) return ts.createSourceFile(fileName, source, languageVersion, true)
|
||||
return baseHost.getSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile)
|
||||
},
|
||||
writeFile() {
|
||||
throw new Error('doc-typecheck: noEmit compilation attempted to write output')
|
||||
},
|
||||
}
|
||||
const program = ts.createProgram([...sources.keys()], options, host)
|
||||
return ts.getPreEmitDiagnostics(program)
|
||||
}
|
||||
|
||||
/** Render compiler diagnostics with virtual block paths mapped back to Markdown. */
|
||||
function formatDiagnostics(diagnostics: readonly ts.Diagnostic[], blocks: Block[]): string {
|
||||
const formatted = ts.formatDiagnostics(diagnostics, {
|
||||
getCanonicalFileName: fileName => fileName,
|
||||
getCurrentDirectory: () => root,
|
||||
getNewLine: () => ts.sys.newLine,
|
||||
})
|
||||
return blocks
|
||||
return remapBlockPaths(formatted, blocks)
|
||||
}
|
||||
|
||||
/** Reuse the repo typecheck graph references from a temp project one directory below root. */
|
||||
function workspaceReferences(): { path: string }[] {
|
||||
const file = join(root, 'tsconfig.json')
|
||||
// Parse with TypeScript's own JSONC reader, not a hand-rolled comment strip:
|
||||
// a regex strip mistakes the `/*/` in a wildcard path candidate
|
||||
// (`./packages/core/*/src`) for a block comment and corrupts the map.
|
||||
const result = ts.readConfigFile(file, p => readFileSync(p, 'utf8'))
|
||||
// Parse with TypeScript's own JSONC reader: a regex comment stripper corrupts the `/*/` path
|
||||
// candidate in the workspace wildcard.
|
||||
const result = ts.readConfigFile(file, path => readFileSync(path, 'utf8'))
|
||||
if (result.error) {
|
||||
throw new Error(`doc-typecheck: cannot read ${file}: ${ts.flattenDiagnosticMessageText(result.error.messageText, '\n')}`)
|
||||
}
|
||||
// `config` is typed `any` by the TS API; narrow it to the one field we read.
|
||||
const { references } = result.config as { compilerOptions: { paths: Record<string, string[]> }; references: { path: string }[] }
|
||||
return references.map(({ path }) => {
|
||||
const relativeToTemp = path.startsWith('./') ? `../${path.slice(2)}` : `../${path}`
|
||||
return { path: relativeToTemp }
|
||||
})
|
||||
// `config` is typed `any` by the TS API; narrow it to the one field read here.
|
||||
const { references } = result.config as { references: { path: string }[] }
|
||||
return references.map(({ path }) => ({
|
||||
path: path.startsWith('./') ? `../${path.slice(2)}` : `../${path}`,
|
||||
}))
|
||||
}
|
||||
|
||||
/** The standalone tsconfig for the temp typecheck project. */
|
||||
/** The standalone temp project used when no coordinated build owns declaration freshness. */
|
||||
function tempTsconfig(): string {
|
||||
return JSON.stringify({
|
||||
extends: '../tsconfig.json',
|
||||
@@ -94,7 +155,40 @@ function tempTsconfig(): string {
|
||||
})
|
||||
}
|
||||
|
||||
const markdownGlobs = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md']
|
||||
/** Compile blocks through project references for the standalone command. */
|
||||
function compileBlocksStandalone(blocks: Block[]): string | undefined {
|
||||
const tmp = mkdtempSync(join(root, '.doc-typecheck-'))
|
||||
try {
|
||||
writeFileSync(join(tmp, 'tsconfig.json'), tempTsconfig())
|
||||
for (const [index, block] of blocks.entries()) {
|
||||
writeFileSync(join(tmp, `block-${index}.ts`), block.code.endsWith('\n') ? block.code : `${block.code}\n`)
|
||||
}
|
||||
try {
|
||||
// Invoke tsc's JS entry through Node instead of a platform-specific shell shim.
|
||||
execFileSync(process.execPath, ['node_modules/typescript/bin/tsc', '-b', join(tmp, 'tsconfig.json')], {
|
||||
cwd: root,
|
||||
stdio: 'pipe',
|
||||
})
|
||||
return undefined
|
||||
} catch (error: unknown) {
|
||||
const failed = error as { stdout?: Buffer; stderr?: Buffer }
|
||||
return remapBlockPaths(`${failed.stdout?.toString() ?? ''}${failed.stderr?.toString() ?? ''}`, blocks)
|
||||
}
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
/** Map virtual or temporary block paths back to their owning Markdown fences. */
|
||||
function remapBlockPaths(output: string, blocks: Block[]): string {
|
||||
return output.replace(/(?:[^\s:()]*[/\\])?block-(\d+)\.ts\((\d+),(\d+)\)/g, (_match, index: string, line: string, column: string) => {
|
||||
const block = blocks[Number(index)]
|
||||
if (!block) return `block-${index}.ts(${line},${column})`
|
||||
return `${block.file} (block at line ${block.line}, +${line}:${column})`
|
||||
})
|
||||
}
|
||||
|
||||
const markdownGlobs = ['README.md', '.agents/notes/**/*.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md']
|
||||
|
||||
const files: string[] = []
|
||||
for (const pattern of markdownGlobs) {
|
||||
@@ -114,45 +208,24 @@ if (checked.length === 0) {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const tmp = mkdtempSync(join(root, '.doc-typecheck-'))
|
||||
try {
|
||||
writeFileSync(join(tmp, 'tsconfig.json'), tempTsconfig())
|
||||
const fileForBlock = new Map<string, Block>()
|
||||
checked.forEach((block, i) => {
|
||||
const name = `block-${i}.ts`
|
||||
writeFileSync(join(tmp, name), block.code.endsWith('\n') ? block.code : `${block.code}\n`)
|
||||
fileForBlock.set(name, block)
|
||||
})
|
||||
|
||||
try {
|
||||
// tsc's JS entry via the current node, not the .bin shim: the extensionless
|
||||
// shim is not spawnable on Windows (the CVE-2024-27980 class the sibling
|
||||
// scripts hit), and the .cmd variant would need shell:true, which
|
||||
// concatenates args UNESCAPED — a hazard for the temp project path. The JS
|
||||
// entry behaves identically on every platform.
|
||||
execFileSync(process.execPath, ['node_modules/typescript/bin/tsc', '-b', join(tmp, 'tsconfig.json')], { cwd: root, stdio: 'pipe' })
|
||||
} catch (error: unknown) {
|
||||
const failed = error as { stdout?: Buffer; stderr?: Buffer }
|
||||
const out = `${failed.stdout?.toString() ?? ''}${failed.stderr?.toString() ?? ''}`
|
||||
// Rewrite "block-N.ts(line,col)" to the real "file:fenceLine" for triage.
|
||||
const remapped = out.replace(/(?:[^\s:()]*[/\\])?block-(\d+)\.ts\((\d+),(\d+)\)/g, (_m, idx: string, ln: string, col: string) => {
|
||||
const block = fileForBlock.get(`block-${idx}.ts`)
|
||||
if (!block) return `block-${idx}.ts(${ln},${col})`
|
||||
return `${block.file} (block at line ${block.line}, +${ln}:${col})`
|
||||
})
|
||||
console.error('doc-typecheck: documentation code blocks failed to compile.\n')
|
||||
console.error(remapped)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const ratio = ignored.length / ratioDenominator
|
||||
const skipped = all.length - ratioDenominator
|
||||
console.log(`doc-typecheck: ${checked.length} block(s) compiled, ${ignored.length} ignored (${(ratio * 100).toFixed(0)}% opt-out), ${skipped} type-equiv/catalog (checked elsewhere).`)
|
||||
// Guard against the escape hatch becoming the norm.
|
||||
if (ratioDenominator >= 4 && ratio > 0.5) {
|
||||
console.error(`doc-typecheck: too many blocks opt out of checking (${ignored.length}/${ratioDenominator}). Make them compile or delete them.`)
|
||||
process.exit(1)
|
||||
}
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
const useBuiltTypes = process.env.DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT === '1'
|
||||
const compilationError = useBuiltTypes
|
||||
? (() => {
|
||||
const diagnostics = compileBlocksAgainstBuiltTypes(checked)
|
||||
return diagnostics.length === 0 ? undefined : formatDiagnostics(diagnostics, checked)
|
||||
})()
|
||||
: compileBlocksStandalone(checked)
|
||||
if (compilationError !== undefined) {
|
||||
console.error('doc-typecheck: documentation code blocks failed to compile.\n')
|
||||
console.error(compilationError)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const ratio = ignored.length / ratioDenominator
|
||||
const skipped = all.length - ratioDenominator
|
||||
console.log(`doc-typecheck: ${checked.length} block(s) compiled, ${ignored.length} ignored (${(ratio * 100).toFixed(0)}% opt-out), ${skipped} type-equiv/catalog (checked elsewhere).`)
|
||||
// Guard against the escape hatch becoming the norm.
|
||||
if (ratioDenominator >= 4 && ratio > 0.5) {
|
||||
console.error(`doc-typecheck: too many blocks opt out of checking (${ignored.length}/${ratioDenominator}). Make them compile or delete them.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -837,7 +837,7 @@ export function render(entries: CatalogEntry[]): string {
|
||||
'',
|
||||
'## Seam packages (not directly loadable)',
|
||||
'',
|
||||
'Abstract service classes — a deployment loads a concrete implementation package instead ([capability seams](rfc/implemented/architecture/2026-06-13-capability-seams.md)).',
|
||||
'Abstract service classes — a deployment loads a concrete implementation package instead ([capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)).',
|
||||
'',
|
||||
...entries.filter(e => e.kind === 'seam').map(e => renderTerse(e, ` — abstract \`${e.className ?? ''}\``)),
|
||||
'',
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Generate the model-facing Cordis API data module from the same event/service
|
||||
* collector as the documentation catalogs. It emits first-sentence docs, raw
|
||||
* signatures, transitive public type shapes, and inherited context entries,
|
||||
* without source pointers; output is deterministic and `--check` verifies it.
|
||||
* collector as the documentation catalogs. It emits original declaration
|
||||
* JSDoc, first-sentence summaries, raw signatures, transitive public type
|
||||
* shapes, and inherited context entries, without source pointers; output is
|
||||
* deterministic and `--check` verifies it.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
@@ -80,7 +81,7 @@ function referencedTypes(seeds: string[], decls: Map<string, string>): { name: s
|
||||
function render(): string {
|
||||
const services = collectServices()
|
||||
const events = collectEvents().sort((a, b) => a.name.localeCompare(b.name))
|
||||
const types = referencedTypes(services.flatMap(service => service.methods), collectTypeDecls())
|
||||
const types = referencedTypes(services.flatMap(service => service.methods.map(method => method.signature)), collectTypeDecls())
|
||||
const lines: string[] = [
|
||||
'/**',
|
||||
' * Generated by scripts/gen-cordis-api.ts — do not edit by hand; run',
|
||||
@@ -88,22 +89,30 @@ function render(): string {
|
||||
' * `pnpm run verify-cordis-api` in doc-sync).',
|
||||
' *',
|
||||
' * The machine-readable cordis API catalog `cordis_inspect` serves to the',
|
||||
' * model: harness services (summary + public method signatures), harness',
|
||||
' * events (mode + signature), and the inherited `ctx` surface. Produced by',
|
||||
' * model: harness services (summary + public method signatures/JSDoc),',
|
||||
' * harness events (mode + signature/JSDoc), and the inherited `ctx` surface. Produced by',
|
||||
' * the same AST walk as docs/cordis-catalog, so this data and the rendered',
|
||||
' * docs cannot diverge.',
|
||||
' *',
|
||||
' * @module @deepseek-ai/dsh-tool-cordis/api-catalog',
|
||||
' */',
|
||||
'',
|
||||
'/** One harness `ctx.<key>` service: its one-line summary and public method signatures. */',
|
||||
'/** One public service method and its source-owned contract. */',
|
||||
'export interface ServiceApiMethod {',
|
||||
' /** Public method signature with its body stripped. */',
|
||||
' signature: string',
|
||||
' /** Original method JSDoc, with only container indentation removed. */',
|
||||
' jsDoc: string',
|
||||
'}',
|
||||
'',
|
||||
'/** One harness `ctx.<key>` service: its one-line summary and public methods. */',
|
||||
'export interface ServiceApiEntry {',
|
||||
' /** The `ctx.<key>` name, e.g. `tools`. */',
|
||||
' key: string',
|
||||
' /** First sentence of the service class JSDoc. */',
|
||||
' summary: string',
|
||||
' /** Public method signatures, bodies stripped, in source order. */',
|
||||
' methods: readonly string[]',
|
||||
' /** Public methods, bodies stripped, in source order. */',
|
||||
' methods: readonly ServiceApiMethod[]',
|
||||
'}',
|
||||
'',
|
||||
'/** One harness event: its dispatch mode, exact signature, and one-line summary. */',
|
||||
@@ -114,6 +123,8 @@ function render(): string {
|
||||
' mode: string',
|
||||
' /** The exact listener signature, whitespace-normalized. */',
|
||||
' signature: string',
|
||||
' /** Original event JSDoc, with only container indentation removed. */',
|
||||
' jsDoc: string',
|
||||
' /** First sentence of the event JSDoc. */',
|
||||
' summary: string',
|
||||
'}',
|
||||
@@ -145,7 +156,12 @@ function render(): string {
|
||||
lines.push(' methods: [],')
|
||||
} else {
|
||||
lines.push(' methods: [')
|
||||
for (const method of service.methods) lines.push(` ${quote(method)},`)
|
||||
for (const method of service.methods) {
|
||||
lines.push(' {')
|
||||
lines.push(` signature: ${quote(method.signature)},`)
|
||||
lines.push(` jsDoc: ${quote(method.jsDoc)},`)
|
||||
lines.push(' },')
|
||||
}
|
||||
lines.push(' ],')
|
||||
}
|
||||
lines.push(' },')
|
||||
@@ -161,6 +177,7 @@ function render(): string {
|
||||
lines.push(` name: ${quote(event.name)},`)
|
||||
lines.push(` mode: ${quote(event.mode)},`)
|
||||
lines.push(` signature: ${quote(event.signature)},`)
|
||||
lines.push(` jsDoc: ${quote(event.jsDoc)},`)
|
||||
lines.push(` summary: ${quote(firstSentence(event.doc))},`)
|
||||
lines.push(' },')
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
/**
|
||||
* Generate the Cordis event and service catalogs from static declarations.
|
||||
* The walk enforces event modes plus JSDoc parameter/return completeness;
|
||||
* inherited Cordis services come from the curated table below. `--check`
|
||||
* verifies both committed artifacts.
|
||||
* The walk enforces event modes, JSDoc parameter/return completeness, and
|
||||
* signature type-link coverage; inherited Cordis services come from the
|
||||
* 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'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT_EVENTS = 'docs/cordis-catalog/events.md'
|
||||
@@ -19,49 +21,226 @@ const OUT_SERVICES = 'docs/cordis-catalog/services.md'
|
||||
const FENCE = 'ts cordis-catalog'
|
||||
|
||||
/**
|
||||
* One primary core-data-structures page per signature type, shared by the
|
||||
* Cordis and config catalogs; union names intentionally do not reuse the
|
||||
* type-equivalence manifest's map-symbol entries.
|
||||
* One primary core-data-structures page per project type used by a generated
|
||||
* signature. This stays curated because union names intentionally do not
|
||||
* reuse the type-equivalence manifest's map-symbol entries and some symbols
|
||||
* appear on more than one page.
|
||||
*/
|
||||
// TODO(catalog-type-links): verify or generate link-map coverage.
|
||||
export const LINK_MAP: Record<string, string> = {
|
||||
Agent: 'core.md',
|
||||
AgentCancelCause: 'core.md',
|
||||
AgentOptions: 'core.md',
|
||||
AgentStatus: 'core.md',
|
||||
ContentBlock: 'core.md',
|
||||
Message: 'core.md',
|
||||
MessageSource: 'core.md',
|
||||
ContinuationDecision: 'core.md',
|
||||
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',
|
||||
MessageSource: 'core.md',
|
||||
PromptDecision: 'core.md',
|
||||
RequestError: 'core.md',
|
||||
RequestErrorDecision: 'core.md',
|
||||
SessionEvent: 'core.md',
|
||||
SessionId: 'core.md',
|
||||
SessionStartSource: 'core.md',
|
||||
StreamChunk: 'llm-streaming.md',
|
||||
TurnEndReason: 'session.md',
|
||||
ToolDefinition: 'tools.md',
|
||||
ToolExecution: 'tools.md',
|
||||
ToolExecutionInput: 'tools.md',
|
||||
ToolExecutionResult: 'tools.md',
|
||||
ToolExecutionToken: 'tools.md',
|
||||
ApprovalOutcome: 'approval.md',
|
||||
ApprovalPolicy: 'approval.md',
|
||||
ApprovalRequest: 'approval.md',
|
||||
ApprovalService: 'approval.md',
|
||||
BashExecRequest: 'bash.md',
|
||||
BashExecSpec: 'bash.md',
|
||||
BashProcess: 'bash.md',
|
||||
BashRunResult: 'bash.md',
|
||||
BashTask: 'bash.md',
|
||||
BashTaskRead: 'bash.md',
|
||||
ConfinedArgv: 'sandbox.md',
|
||||
SandboxMode: 'sandbox.md',
|
||||
SandboxPolicy: 'sandbox.md',
|
||||
DshEnvironment: 'bash.md',
|
||||
CodeRunRequest: 'code-runtime.md',
|
||||
CodeRunResult: 'code-runtime.md',
|
||||
CompactionResult: 'compaction.md',
|
||||
CompactionTrigger: 'compaction.md',
|
||||
PruneResult: 'compaction.md',
|
||||
FileReadOutcome: 'filesystem.md',
|
||||
FsDirEntry: 'filesystem.md',
|
||||
FsEditOutcome: 'filesystem.md',
|
||||
FsEditRequest: 'filesystem.md',
|
||||
FsInfo: 'filesystem.md',
|
||||
FsPathInfo: 'filesystem.md',
|
||||
FsPolicyExec: 'filesystem.md',
|
||||
FsTarget: 'filesystem.md',
|
||||
FsVersion: 'filesystem.md',
|
||||
FsWriteIntent: 'filesystem.md',
|
||||
FsWriteOutcome: 'filesystem.md',
|
||||
FsPolicyExec: 'filesystem.md',
|
||||
FileReadOutcome: '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',
|
||||
CreateSessionOptions: 'persistence.md',
|
||||
SessionHeader: 'persistence.md',
|
||||
SessionLocation: 'persistence.md',
|
||||
ConfinedArgv: 'sandbox.md',
|
||||
SandboxExecutionPolicy: 'sandbox.md',
|
||||
SandboxMode: 'sandbox.md',
|
||||
SandboxPolicy: 'sandbox.md',
|
||||
SandboxPolicyRequest: 'sandbox.md',
|
||||
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',
|
||||
SessionEventTraceRequest: 'session-query.md',
|
||||
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',
|
||||
SkillRegistration: 'skills.md',
|
||||
SkillSummary: 'skills.md',
|
||||
SaveTextSpill: 'spill.md',
|
||||
SpillRef: 'spill.md',
|
||||
SubagentProvider: 'subagent.md',
|
||||
SubagentRun: 'subagent.md',
|
||||
SubagentService: 'subagent.md',
|
||||
SubagentStartRequest: 'subagent.md',
|
||||
AssembleContext: 'system-prompt.md',
|
||||
PromptSection: 'system-prompt.md',
|
||||
SystemPrompt: 'system-prompt.md',
|
||||
ToolProviderResult: 'system-prompt.md',
|
||||
TaskDoneListener: 'tasks.md',
|
||||
TaskId: 'tasks.md',
|
||||
TaskRead: 'tasks.md',
|
||||
TaskSnapshot: 'tasks.md',
|
||||
TaskStart: 'tasks.md',
|
||||
TokenMeasurement: 'token-meter.md',
|
||||
PostToolDecision: 'tools.md',
|
||||
PreToolDecision: 'tools.md',
|
||||
ToolDefinition: 'tools.md',
|
||||
ToolExecution: 'tools.md',
|
||||
ToolDispatchExecution: 'tools.md',
|
||||
ToolExecutionInput: 'tools.md',
|
||||
ToolExecutionMode: 'tools.md',
|
||||
ToolExecutionResult: 'tools.md',
|
||||
ToolExecutionToken: 'tools.md',
|
||||
ToolGuard: 'tools.md',
|
||||
ToolRegistry: 'tools.md',
|
||||
ToolRestriction: 'tools.md',
|
||||
ToolSchema: 'tools.md',
|
||||
AskUserQuestionAnswer: 'user-interaction.md',
|
||||
AskUserQuestionRequest: 'user-interaction.md',
|
||||
UserInteractionProvider: 'user-interaction.md',
|
||||
WebFetchProvider: 'web.md',
|
||||
WebFetchRequest: 'web.md',
|
||||
WebFetchResult: 'web.md',
|
||||
WebSearchProvider: 'web.md',
|
||||
WebSearchRequest: 'web.md',
|
||||
WebSearchResult: 'web.md',
|
||||
WorkflowRun: 'workflow.md',
|
||||
WorkflowRunInfo: 'workflow.md',
|
||||
WorkflowStartRequest: 'workflow.md',
|
||||
}
|
||||
|
||||
/** TypeScript lib and pinned framework types that have no repository-owned data page. */
|
||||
const FOUNDATION_TYPE_NAMES = new Set([
|
||||
'AbortSignal',
|
||||
'AsyncIterable',
|
||||
'Context',
|
||||
'Error',
|
||||
'Pick',
|
||||
'Promise',
|
||||
'Readonly',
|
||||
])
|
||||
|
||||
/** Project types deliberately documented outside the core-data catalog. */
|
||||
const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
AgentFactory: 'agent creation seam is owned by packages/core/agent/README.md',
|
||||
AgentHandle: 'agent ownership handle is owned by packages/core/agent/README.md',
|
||||
BashEnvContributor: 'service-local extension type is owned by packages/bash/tool-bash/src/index.ts',
|
||||
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',
|
||||
ResumeAgentOptions: 'agent resume contract is owned by packages/core/agent/README.md',
|
||||
SessionForkSource: 'service-local fork input is owned by packages/core/session/src/index.ts',
|
||||
SubagentRunEndInfo: 'event-local snapshot is owned by packages/subagent/subagent/src/index.ts',
|
||||
SubagentRunInfo: 'event-local snapshot is owned by packages/subagent/subagent/src/index.ts',
|
||||
WorkflowAgentEndInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
|
||||
WorkflowAgentInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
|
||||
WorkflowResultInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
|
||||
}
|
||||
|
||||
/** Collect named references from parameter, generic-constraint/default, and return types. */
|
||||
function signatureTypeNames(member: ts.MethodSignature | ts.MethodDeclaration, sf: ts.SourceFile): string[] {
|
||||
const declared = new Set(member.typeParameters?.map(parameter => parameter.name.text) ?? [])
|
||||
const referenced = new Set<string>()
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isTypeReferenceNode(node)) referenced.add(node.typeName.getText(sf))
|
||||
if (ts.isTypeQueryNode(node)) referenced.add(node.exprName.getText(sf))
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
for (const parameter of member.typeParameters ?? []) {
|
||||
if (parameter.constraint) visit(parameter.constraint)
|
||||
if (parameter.default) visit(parameter.default)
|
||||
}
|
||||
for (const parameter of member.parameters) {
|
||||
if (parameter.type) visit(parameter.type)
|
||||
}
|
||||
if (member.type) visit(member.type)
|
||||
return [...referenced].filter(name => !declared.has(name)).sort()
|
||||
}
|
||||
|
||||
/** Append fail-closed signature type-link violations with actionable ownership choices. */
|
||||
function checkTypeLinks(
|
||||
where: string,
|
||||
member: ts.MethodSignature | ts.MethodDeclaration,
|
||||
sf: ts.SourceFile,
|
||||
violations: string[],
|
||||
): void {
|
||||
for (const name of signatureTypeNames(member, sf)) {
|
||||
if (Object.hasOwn(LINK_MAP, name)
|
||||
|| FOUNDATION_TYPE_NAMES.has(name)
|
||||
|| Object.hasOwn(TYPE_LINK_EXEMPTIONS, name)) continue
|
||||
violations.push(
|
||||
`${where} references unclassified type '${name}'. Add it to LINK_MAP with its core-data-structures page, `
|
||||
+ 'to FOUNDATION_TYPE_NAMES if TypeScript or Cordis owns it, or to TYPE_LINK_EXEMPTIONS with '
|
||||
+ 'the non-catalog documentation owner.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Throw one aggregated diagnostic for every unclassified signature type. */
|
||||
function reportTypeLinkViolations(gate: string, violations: string[]): void {
|
||||
if (violations.length === 0) return
|
||||
throw new Error(
|
||||
`${gate}: ${violations.length} signature type-link coverage violation(s):\n`
|
||||
+ violations.map(violation => ` ${violation}`).join('\n'),
|
||||
)
|
||||
}
|
||||
|
||||
/** One harness event, extracted from an `interface Events` block. */
|
||||
@@ -72,6 +251,8 @@ interface EventEntry {
|
||||
scope: string
|
||||
/** Full signature text (the method-signature member, JSDoc stripped). */
|
||||
signature: string
|
||||
/** Original declaration JSDoc, dedented from its containing interface. */
|
||||
jsDoc: string
|
||||
/** Dispatch mode from the `@mode` tag. */
|
||||
mode: Mode
|
||||
/** Description prose (JSDoc minus the `@mode` tag), one line per paragraph. */
|
||||
@@ -80,6 +261,14 @@ interface EventEntry {
|
||||
source: string
|
||||
}
|
||||
|
||||
/** One public service method and the source contract attached to it. */
|
||||
interface ServiceMethodEntry {
|
||||
/** Public method signature (body stripped). */
|
||||
signature: string
|
||||
/** Original method JSDoc, dedented from its containing class. */
|
||||
jsDoc: string
|
||||
}
|
||||
|
||||
/** One harness service, extracted from an `interface Context` block. */
|
||||
interface ServiceEntry {
|
||||
/** The `ctx.<key>` name, e.g. `llm`. */
|
||||
@@ -90,8 +279,8 @@ interface ServiceEntry {
|
||||
abstract: boolean
|
||||
/** Class-level JSDoc prose, one line per paragraph. */
|
||||
doc: string
|
||||
/** Public method signatures (bodies stripped), in source order. */
|
||||
methods: string[]
|
||||
/** Public methods (bodies stripped), in source order. */
|
||||
methods: ServiceMethodEntry[]
|
||||
/** Source pointer of the class declaration. */
|
||||
source: string
|
||||
}
|
||||
@@ -104,15 +293,7 @@ interface InheritedEntry {
|
||||
source: string
|
||||
}
|
||||
|
||||
/** Find the `declare module 'cordis'` body in a source file, or null. */
|
||||
function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
|
||||
for (const stmt of sf.statements) {
|
||||
if (ts.isModuleDeclaration(stmt) && ts.isStringLiteral(stmt.name) && stmt.name.text === 'cordis') {
|
||||
if (stmt.body && ts.isModuleBlock(stmt.body)) return stmt.body
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
// cordisModuleBody / eventMembers / serviceClasses live in cordis-walk.ts.
|
||||
|
||||
/** The signature text of a method-signature member (everything but a body). */
|
||||
function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.SourceFile): string {
|
||||
@@ -122,6 +303,22 @@ function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.Source
|
||||
return sig.replace(/\s*;?\s*$/, '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a node's original JSDoc while removing only the indentation imposed by
|
||||
* its containing interface or class.
|
||||
*/
|
||||
function jsDocText(text: string, sf: ts.SourceFile, node: ts.Node): string {
|
||||
const raw = rawJsDoc(text, node)
|
||||
if (!raw) return ''
|
||||
const start = text.lastIndexOf(raw, node.getStart(sf))
|
||||
const { line } = sf.getLineAndCharacterOfPosition(start)
|
||||
const lineStart = sf.getPositionOfLineAndCharacter(line, 0)
|
||||
const indent = text.slice(lineStart, start)
|
||||
return raw.split('\n')
|
||||
.map((lineText, index) => index > 0 && lineText.startsWith(indent) ? lineText.slice(indent.length) : lineText)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
/** Walk every harness `interface Events` block and extract its events, hard-
|
||||
* erroring (aggregated) on any JSDoc-completeness violation: a missing/
|
||||
* contradicted `@mode`, missing description prose, or an undocumented payload
|
||||
@@ -129,6 +326,7 @@ function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.Source
|
||||
export function collectEvents(scanRoot: string = root): EventEntry[] {
|
||||
const entries: EventEntry[] = []
|
||||
const violations: string[] = []
|
||||
const typeLinkViolations: string[] = []
|
||||
for (const rel of globSync('packages/*/*/src/*.ts', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) {
|
||||
const abs = resolve(scanRoot, rel)
|
||||
const text = readFileSync(abs, 'utf8')
|
||||
@@ -136,41 +334,38 @@ export function collectEvents(scanRoot: string = root): EventEntry[] {
|
||||
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
|
||||
const body = cordisModuleBody(sf)
|
||||
if (!body) continue
|
||||
for (const stmt of body.statements) {
|
||||
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Events') continue
|
||||
for (const member of stmt.members) {
|
||||
if (!ts.isMethodSignature(member)) continue
|
||||
const name = ts.isStringLiteral(member.name) ? member.name.text : member.name.getText(sf)
|
||||
const signature = memberSignature(member, sf)
|
||||
const raw = rawJsDoc(text, member)
|
||||
const { doc, mode } = parseJsDoc(raw)
|
||||
const src = pointer(rel, sf, member)
|
||||
const where = `event '${name}' (${src})`
|
||||
if (!mode) {
|
||||
violations.push(`${where} is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`)
|
||||
}
|
||||
// Conclusive structural check: a trailing `next: () => …` parameter is a
|
||||
// waterfall. (emit vs parallel vs serial is not structurally
|
||||
// distinguishable, so it is trusted from the tag.)
|
||||
const last = member.parameters.at(-1)
|
||||
const hasNext = !!last && last.name.getText(sf) === 'next'
|
||||
if (mode && hasNext && mode !== 'waterfall') {
|
||||
violations.push(`${where} has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`)
|
||||
}
|
||||
if (mode && !hasNext && mode === 'waterfall') {
|
||||
violations.push(`${where} is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`)
|
||||
}
|
||||
if (!doc) violations.push(`${where} has no description prose. Say what happened / what a listener may do, above the block tags.`)
|
||||
// Payload parameters need a non-empty @param. The `this` receiver is not
|
||||
// payload, and a waterfall's trailing `next` is covered by its mode.
|
||||
const { params } = parseTags(raw)
|
||||
checkParams(where, 'event', member.parameters, params, sf,
|
||||
p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations)
|
||||
if (mode) entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src })
|
||||
for (const { name, member } of eventMembers(body, sf)) {
|
||||
const signature = memberSignature(member, sf)
|
||||
const raw = rawJsDoc(text, member)
|
||||
const { doc, mode } = parseJsDoc(raw)
|
||||
const src = pointer(rel, sf, member)
|
||||
const where = `event '${name}' (${src})`
|
||||
checkTypeLinks(where, member, sf, typeLinkViolations)
|
||||
if (!mode) {
|
||||
violations.push(`${where} is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`)
|
||||
}
|
||||
// Conclusive structural check: a trailing `next: () => …` parameter is a
|
||||
// waterfall. (emit vs parallel vs serial is not structurally
|
||||
// distinguishable, so it is trusted from the tag.)
|
||||
const last = member.parameters.at(-1)
|
||||
const hasNext = !!last && last.name.getText(sf) === 'next'
|
||||
if (mode && hasNext && mode !== 'waterfall') {
|
||||
violations.push(`${where} has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`)
|
||||
}
|
||||
if (mode && !hasNext && mode === 'waterfall') {
|
||||
violations.push(`${where} is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`)
|
||||
}
|
||||
if (!doc) violations.push(`${where} has no description prose. Say what happened / what a listener may do, above the block tags.`)
|
||||
// Payload parameters need a non-empty @param. The `this` receiver is not
|
||||
// payload, and a waterfall's trailing `next` is covered by its mode.
|
||||
const { params } = parseTags(raw)
|
||||
checkParams(where, 'event', member.parameters, params, sf,
|
||||
p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations)
|
||||
if (mode) entries.push({ name, scope: name.split('/')[0] ?? name, signature, jsDoc: jsDocText(text, sf, member), mode, doc, source: src })
|
||||
}
|
||||
}
|
||||
reportViolations('gen-cordis-catalog', violations)
|
||||
reportTypeLinkViolations('gen-cordis-catalog', typeLinkViolations)
|
||||
return entries
|
||||
}
|
||||
|
||||
@@ -183,6 +378,7 @@ export function collectEvents(scanRoot: string = root): EventEntry[] {
|
||||
export function collectServices(scanRoot: string = root): ServiceEntry[] {
|
||||
const entries: ServiceEntry[] = []
|
||||
const violations: string[] = []
|
||||
const typeLinkViolations: string[] = []
|
||||
for (const rel of globSync('packages/*/*/src/index.ts', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) {
|
||||
const abs = resolve(scanRoot, rel)
|
||||
const text = readFileSync(abs, 'utf8')
|
||||
@@ -190,27 +386,9 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
|
||||
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
|
||||
const body = cordisModuleBody(sf)
|
||||
if (!body) continue
|
||||
// The ctx key → type mapping(s) declared in this file's interface Context.
|
||||
const keyToType = new Map<string, string>()
|
||||
for (const stmt of body.statements) {
|
||||
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Context') continue
|
||||
for (const member of stmt.members) {
|
||||
if (!ts.isPropertySignature(member) || !member.type) continue
|
||||
const key = member.name.getText(sf)
|
||||
keyToType.set(key, member.type.getText(sf))
|
||||
}
|
||||
}
|
||||
if (keyToType.size === 0) continue
|
||||
// Find each service class declared in the same file and emit an entry.
|
||||
for (const [key, type] of keyToType) {
|
||||
const cls = sf.statements.find(
|
||||
(s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === type,
|
||||
)
|
||||
if (!cls) continue // a Pick-mixin member (e.g. timer helpers), not a class here
|
||||
const abstract = cls.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false
|
||||
const clsDoc = parseJsDoc(rawJsDoc(text, cls)).doc
|
||||
if (!clsDoc) violations.push(`service ctx.${key} (${pointer(rel, sf, cls)}): class ${type} has no JSDoc.`)
|
||||
const methods: string[] = []
|
||||
// Resolve each ctx key to its service class (shared walk) and emit an entry.
|
||||
for (const { key, type, cls, abstract, doc: clsDoc } of serviceClasses(body, sf, rel, violations)) {
|
||||
const methods: ServiceMethodEntry[] = []
|
||||
for (const member of cls.members) {
|
||||
if (!ts.isMethodDeclaration(member)) continue
|
||||
// Only instance methods callable through `ctx.<key>` are surface;
|
||||
@@ -223,9 +401,10 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
|
||||
if (nonPublic) continue
|
||||
const memberName = member.name.getText(sf)
|
||||
if (memberName.startsWith('[')) continue // computed/symbol members
|
||||
methods.push(memberSignature(member, sf))
|
||||
const where = `service method ctx.${key}.${memberName} (${pointer(rel, sf, member)})`
|
||||
checkTypeLinks(where, member, sf, typeLinkViolations)
|
||||
const raw = rawJsDoc(text, member)
|
||||
methods.push({ signature: memberSignature(member, sf), jsDoc: jsDocText(text, sf, member) })
|
||||
if (!raw) { violations.push(`${where} has no JSDoc.`); continue }
|
||||
if (!parseJsDoc(raw).doc) violations.push(`${where} has no description prose above its block tags.`)
|
||||
const { params, returns } = parseTags(raw)
|
||||
@@ -247,6 +426,7 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
|
||||
}
|
||||
}
|
||||
reportViolations('gen-cordis-catalog', violations)
|
||||
reportTypeLinkViolations('gen-cordis-catalog', typeLinkViolations)
|
||||
return entries.sort((a, b) => a.key.localeCompare(b.key))
|
||||
}
|
||||
|
||||
@@ -260,14 +440,14 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
|
||||
* sibling check is N/A; keep them current on a vendor bump.
|
||||
*/
|
||||
const INHERITED_EVENTS: InheritedEntry[] = [
|
||||
{ name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:197' },
|
||||
{ name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:198' },
|
||||
{ name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:199' },
|
||||
{ name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:200' },
|
||||
{ name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:201' },
|
||||
{ name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:202' },
|
||||
{ name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:203' },
|
||||
{ name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:204' },
|
||||
{ name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:328' },
|
||||
{ name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:330' },
|
||||
{ name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:332' },
|
||||
{ name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:334' },
|
||||
{ name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:336' },
|
||||
{ name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:338' },
|
||||
{ name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:340' },
|
||||
{ name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:342' },
|
||||
{ name: 'hmr/change', summary: 'A watched source file changed on disk.', source: 'vendor/hmr/src/index.ts:20' },
|
||||
{ name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:21' },
|
||||
{ name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' },
|
||||
@@ -278,12 +458,12 @@ const INHERITED_EVENTS: InheritedEntry[] = [
|
||||
]
|
||||
|
||||
export const INHERITED_SERVICES: InheritedEntry[] = [
|
||||
{ name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:29' },
|
||||
{ name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / veto-chain).', source: 'vendor/cordis/src/events.ts:29' },
|
||||
{ name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:144' },
|
||||
{ name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:34' },
|
||||
{ name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / veto-chain).', source: 'vendor/cordis/src/events.ts:34' },
|
||||
{ name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:164' },
|
||||
{ name: 'ctx.effect', summary: 'Register a disposable side effect tied to the fiber.', source: 'vendor/cordis/src/fiber.ts:9' },
|
||||
{ name: 'ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin', summary: 'Low-level service-store access and binding.', source: 'vendor/cordis/src/reflect.ts:7' },
|
||||
{ name: 'ctx.extend / ctx.isolate / ctx.intercept', summary: 'Derive a child context (scoped services / isolation / interception).', source: 'vendor/cordis/src/context.ts:35' },
|
||||
{ name: 'ctx.extend / ctx.isolate / ctx.intercept', summary: 'Derive a child context (scoped services / isolation / interception).', source: 'vendor/cordis/src/context.ts:42' },
|
||||
{ name: 'ctx.root / ctx.scope / ctx.fiber / ctx.registry / ctx.reflect / ctx.events / ctx.logger', summary: 'Ambient handles onto the running context graph.', source: 'vendor/cordis/src/context.ts:16' },
|
||||
{ name: 'ctx.timer (+ interval / timeout / throttle / debounce / setTimeout / setInterval)', summary: 'Disposable timer helpers. The `timer` key is provided at runtime; the six helpers are mixed onto ctx directly (declared via Pick).', source: 'vendor/timer/src/index.ts:4' },
|
||||
{ name: 'ctx.loader', summary: 'The config Loader that booted the app (present under the loader).', source: 'vendor/loader/src/index.ts:30' },
|
||||
@@ -305,7 +485,7 @@ function typeLinks(signature: string): string {
|
||||
function renderEvent(e: EventEntry): string[] {
|
||||
const out = [`### \`${e.name}\` — ${e.mode}`, '']
|
||||
if (e.doc) out.push(e.doc, '')
|
||||
out.push('```' + FENCE, e.signature, '```', '')
|
||||
out.push('```' + FENCE, e.jsDoc, e.signature, '```', '')
|
||||
const links = typeLinks(e.signature)
|
||||
if (links) out.push(links, '')
|
||||
out.push(`Source: [\`${e.source}\`](../../${e.source.split(':')[0]})`, '')
|
||||
@@ -318,8 +498,13 @@ function renderService(s: ServiceEntry): string[] {
|
||||
const out = [`## \`ctx.${s.key}\` — \`${s.type}\`${kind}`, '']
|
||||
if (s.doc) out.push(s.doc, '')
|
||||
if (s.methods.length) {
|
||||
out.push('```' + FENCE, ...s.methods, '```', '')
|
||||
const links = typeLinks(s.methods.join('\n'))
|
||||
const declarations = s.methods.flatMap((method, index) => [
|
||||
...(index > 0 ? [''] : []),
|
||||
method.jsDoc,
|
||||
method.signature,
|
||||
])
|
||||
out.push('```' + FENCE, ...declarations, '```', '')
|
||||
const links = typeLinks(s.methods.map(method => method.signature).join('\n'))
|
||||
if (links) out.push(links, '')
|
||||
}
|
||||
out.push(`Source: [\`${s.source}\`](../../${s.source.split(':')[0]})`, '')
|
||||
@@ -334,19 +519,19 @@ const BANNER = [
|
||||
]
|
||||
|
||||
/** The shared GENERATED + freshness-gate + fence notice paragraph. */
|
||||
const GATE_NOTICE = 'This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence (skipped by doc-typecheck, since a bare signature is not standalone-compilable). Type names in a signature link to the page that documents them.'
|
||||
const GATE_NOTICE = 'This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence and include the original source JSDoc immediately before each event or service method. doc-typecheck skips these bare declaration fragments; type names in a signature link to the page that documents them.'
|
||||
|
||||
/** Render the events catalog (pure, deterministic given sorted inputs). */
|
||||
function renderEvents(events: EventEntry[]): string {
|
||||
export function renderEvents(events: EventEntry[]): string {
|
||||
const lines: string[] = [
|
||||
...BANNER,
|
||||
'# Cordis Events Catalog',
|
||||
'',
|
||||
'Every cordis event a plugin can listen to: exact signature, dispatch mode, and the declaration\'s JSDoc. This is one axis of the **wiring** reference a plugin author works against — the callable `ctx.<key>` surface is the sibling [services catalog](services.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around.',
|
||||
'Every cordis event a plugin can listen to: exact signature, dispatch mode, and original declaration JSDoc. This is one axis of the **wiring** reference a plugin author works against — the callable `ctx.<key>` surface is the sibling [services catalog](services.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around.',
|
||||
'',
|
||||
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`).',
|
||||
'',
|
||||
@@ -372,16 +557,16 @@ function renderEvents(events: EventEntry[]): string {
|
||||
}
|
||||
|
||||
/** Render the services catalog (pure, deterministic given sorted inputs). */
|
||||
function renderServices(services: ServiceEntry[]): string {
|
||||
export function renderServices(services: ServiceEntry[]): string {
|
||||
const lines: string[] = [
|
||||
...BANNER,
|
||||
'# Cordis Services Catalog',
|
||||
'',
|
||||
'Every `ctx.<key>` service a plugin can call: the exact public interface plus the class JSDoc. This is one axis of the **wiring** reference a plugin author works against — the events a plugin listens to are the sibling [events catalog](events.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against.',
|
||||
'Every `ctx.<key>` service a plugin can call: the exact public interface with original method JSDoc, plus the class JSDoc. This is one axis of the **wiring** reference a plugin author works against — the events a plugin listens to are the sibling [events catalog](events.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against.',
|
||||
'',
|
||||
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))
|
||||
@@ -405,6 +590,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[] = []
|
||||
@@ -421,15 +607,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,18 +58,24 @@ const GROUP_ORDER = [
|
||||
'util',
|
||||
'llm',
|
||||
'core',
|
||||
'goal',
|
||||
'bash',
|
||||
'sandbox',
|
||||
'fs',
|
||||
'skill',
|
||||
'compact',
|
||||
'subagent',
|
||||
'tasks',
|
||||
'workflow',
|
||||
'web',
|
||||
'spill',
|
||||
'todo',
|
||||
'plan',
|
||||
'cordis',
|
||||
'hooks',
|
||||
'session-persistence',
|
||||
'session-query',
|
||||
'session-title',
|
||||
'support',
|
||||
'ui',
|
||||
]
|
||||
@@ -84,29 +90,61 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
consumers: ['agent-loop', 'compact-basic'],
|
||||
note: 'Adapters register provider implementations; the loop and compaction call the provider-neutral stream service.',
|
||||
},
|
||||
{
|
||||
key: 'tokenMeter',
|
||||
pkg: 'token-meter',
|
||||
title: 'Replay token measurement',
|
||||
mode: 'core',
|
||||
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', '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',
|
||||
title: 'Durable session persistence seam',
|
||||
mode: 'seam',
|
||||
implementations: ['session-persistence-jsonl', 'session-persistence-sqlite'],
|
||||
consumers: ['agent-loop', 'acp', 'session-query'],
|
||||
consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'acp', 'session-query'],
|
||||
note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.',
|
||||
},
|
||||
{
|
||||
key: 'sessionQuery',
|
||||
pkg: 'session-query',
|
||||
title: 'Exact session-history reads',
|
||||
title: 'Exact session-history reads and traces',
|
||||
mode: 'seam',
|
||||
note: 'Resolves live and optional persisted logs into one logical corpus for exact reads.',
|
||||
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',
|
||||
@@ -129,10 +167,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',
|
||||
@@ -145,10 +199,10 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
{
|
||||
key: 'agents',
|
||||
pkg: 'agent',
|
||||
title: 'Agent registry',
|
||||
title: 'Agent service',
|
||||
mode: 'core',
|
||||
consumers: ['agent-loop', 'acp', 'subagent-inprocess', 'stdio-demo', 'invariants'],
|
||||
note: 'Owns live Agent handles and the create/resume factory seam.',
|
||||
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.',
|
||||
},
|
||||
{
|
||||
key: 'agentLoop',
|
||||
@@ -158,6 +212,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',
|
||||
@@ -167,6 +228,13 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
consumers: ['tool-bash', 'hooks-claude', 'hooks-codex'],
|
||||
note: 'The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them.',
|
||||
},
|
||||
{
|
||||
key: 'bashEnv',
|
||||
pkg: 'tool-bash',
|
||||
title: 'Managed bash environment registry',
|
||||
mode: 'core',
|
||||
note: 'Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace.',
|
||||
},
|
||||
{
|
||||
key: 'sandbox',
|
||||
pkg: 'sandbox',
|
||||
@@ -176,6 +244,15 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
consumers: ['bash-sandbox'],
|
||||
note: 'Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement.',
|
||||
},
|
||||
{
|
||||
key: 'sandboxPolicy',
|
||||
pkg: 'sandbox-policy',
|
||||
title: 'Sandbox policy home',
|
||||
mode: 'core',
|
||||
implementations: [],
|
||||
consumers: ['bash-sandbox', 'fs-sandbox'],
|
||||
note: 'The one home for the deployment default mode + workspace root; only the sandboxed executor and provider read the service (the tool layers use the pure `sandbox/mode` fold it also exports). Both enforcing families read it so bash and fs cannot confine to different roots.',
|
||||
},
|
||||
{
|
||||
key: 'approval',
|
||||
pkg: 'approval',
|
||||
@@ -208,10 +285,10 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
pkg: 'fs',
|
||||
title: 'Filesystem provider seam',
|
||||
mode: 'seam',
|
||||
implementations: ['fs-local'],
|
||||
implementations: ['fs-local', 'fs-sandbox'],
|
||||
consumers: ['tool-fs'],
|
||||
companions: ['fs-policy'],
|
||||
note: 'tool-fs executes read/write/edit through ctx.fs; fs-policy contributes observed-state checks through the fs/* event gate.',
|
||||
note: 'tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-policy contributes observed-state checks through the fs/* event gate.',
|
||||
},
|
||||
{
|
||||
key: 'compact',
|
||||
@@ -220,16 +297,24 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
mode: 'seam',
|
||||
implementations: ['compact-basic'],
|
||||
consumers: ['compact-basic'],
|
||||
note: 'The basic backend currently consumes the pre-step event directly; a model-facing compact tool remains deferred.',
|
||||
note: 'The basic backend consumes post-step pressure and request-error recovery events; a model-facing compact tool remains deferred.',
|
||||
},
|
||||
{
|
||||
key: 'subagents',
|
||||
pkg: 'subagent',
|
||||
title: 'Subagent provider registry',
|
||||
mode: 'seam',
|
||||
implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp', 'subagent-mock'],
|
||||
consumers: ['tool-subagent'],
|
||||
note: 'Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name.',
|
||||
implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp'],
|
||||
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',
|
||||
pkg: 'tasks',
|
||||
title: 'Background task registry',
|
||||
mode: 'core',
|
||||
consumers: ['tool-bash', 'tool-subagent', 'tool-tasks'],
|
||||
note: 'Producers (tool-bash background commands, tool-subagent background delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it.',
|
||||
},
|
||||
{
|
||||
key: 'web',
|
||||
@@ -240,14 +325,23 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
consumers: ['tool-web'],
|
||||
note: 'Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names.',
|
||||
},
|
||||
{
|
||||
key: 'spillStore',
|
||||
pkg: 'spill',
|
||||
title: 'Spill storage seam',
|
||||
mode: 'seam',
|
||||
implementations: ['spill-local'],
|
||||
consumers: ['spill-policy'],
|
||||
note: 'The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill.',
|
||||
},
|
||||
{
|
||||
key: 'workflows',
|
||||
pkg: 'workflow',
|
||||
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.',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -384,20 +478,20 @@ 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: '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 combines the real DeepSeek adapter, coding tools, compaction, subagents, and workflows with the full-screen terminal app package.',
|
||||
},
|
||||
{
|
||||
id: 'coding',
|
||||
rel: 'examples/coding-agent/composition.md',
|
||||
title: 'Coding Agent App Composition',
|
||||
label: 'examples/coding-agent',
|
||||
config: 'examples/coding-agent/cordis.yml',
|
||||
summary: 'The coding REPL demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package.',
|
||||
id: 'headless',
|
||||
rel: 'examples/headless-agent/composition.md',
|
||||
title: 'Headless Agent App Composition',
|
||||
label: 'examples/headless-agent',
|
||||
config: 'examples/headless-agent/cordis.yml',
|
||||
summary: 'The headless demo combines the real DeepSeek adapter and coding capabilities with the one-shot app package, format-pure stdout, and one fresh persisted top-level session.',
|
||||
},
|
||||
{
|
||||
id: 'cordis',
|
||||
@@ -424,8 +518,10 @@ function renderAppExpansion(lines: string[], appNode: string, pluginName: string
|
||||
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') {
|
||||
lines.push(` ${appNode} --> ${nodeId('frontdoor', 'stdio')}["readline UI<br/>console logger<br/>pre-created main agent"]`)
|
||||
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') {
|
||||
lines.push(` ${appNode} --> ${nodeId('frontdoor', 'acp')}["@deepseek-ai/dsh-acp<br/>JSON-RPC stdio bridge<br/>sessions created by client"]`)
|
||||
}
|
||||
@@ -452,7 +548,7 @@ 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-acp-demo') {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -810,19 +906,40 @@ function renderLifecycle(): string {
|
||||
' LLM-->>Driver: StreamChunk*',
|
||||
` Driver->>Session: ${mermaidCode('assistant/chunk')}*`,
|
||||
` Session-->>SDK: ${mermaidCode('session/event')} ${mermaidCode('assistant/chunk')}*`,
|
||||
' alt final adapter or terminal in-band request failure',
|
||||
` Driver->>Session: ${mermaidCode('step/end')}`,
|
||||
` Driver->>Hooks: ${mermaidCode('agent/request-error')} waterfall`,
|
||||
' Hooks-->>Driver: retry in a new step or preserve the original error',
|
||||
' else model request succeeded',
|
||||
` Driver->>Hooks: ${mermaidCode('agent/step-result')} waterfall`,
|
||||
` Driver->>Session: ${mermaidCode('assistant/message')}`,
|
||||
` Driver->>Session: ${mermaidCode('tool/call')}`,
|
||||
' Driver->>Tools: execute through pre and post waterfalls',
|
||||
' Tools-->>Session: tool-owned events when applicable',
|
||||
` Driver->>Session: ${mermaidCode('tool/result')} and ${mermaidCode('step/end')}`,
|
||||
' Driver->>Tools: classify pending call by executionMode',
|
||||
' loop barriers and bounded rolling pool, reclassify before start',
|
||||
' opt call starts',
|
||||
` Driver->>Session: ${mermaidCode('tool/call')}`,
|
||||
' Driver->>Tools: ordered pre, concurrent execute',
|
||||
' Tools-->>Session: tool-owned events when applicable',
|
||||
' end',
|
||||
' opt next model-order result ready',
|
||||
' Driver->>Tools: ordered post',
|
||||
` Driver->>Session: ${mermaidCode('tool/result')}`,
|
||||
' end',
|
||||
' end',
|
||||
' Driver->>Session: post-tool context and steering',
|
||||
` Driver->>Hooks: ${mermaidCode('agent/post-step')} serial checkpoint`,
|
||||
` Driver->>Session: ${mermaidCode('step/end')}`,
|
||||
` Driver->>Hooks: ${mermaidCode('agent/turn-continuation')} waterfall`,
|
||||
` Driver->>Hooks: ${mermaidCode('agent/turn-stop')} serial terminal checkpoint`,
|
||||
' end',
|
||||
` Driver->>Session: ${mermaidCode('turn/end')}`,
|
||||
` Driver->>Persistence: ${mermaidCode('session/flush')} parallel checkpoint`,
|
||||
` Driver-->>SDK: ${mermaidCode('agent/status')} idle`,
|
||||
'```',
|
||||
'',
|
||||
'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. 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.',
|
||||
'',
|
||||
...maintenanceFooter(maintenance),
|
||||
@@ -850,9 +967,9 @@ function renderToolPipeline(): string {
|
||||
` owned["Tool-owned session events<br/>${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/code-dispatch')}"]`,
|
||||
` post["${mermaidCode('tools/post-execute')} waterfall<br/>accept, block, replace, add context"]`,
|
||||
` final["${mermaidCode('tools/result')} synchronous notification<br/>frozen authoritative outcome"]`,
|
||||
' context["Buffered additionalContext<br/>context/message after all tool results"]',
|
||||
' context["Active-batch additionalContexts FIFO<br/>context/message after recorded tool results"]',
|
||||
` toolResult["Session event: ${mermaidCode('tool/result')}<br/>single model-facing outcome"]`,
|
||||
' allResults["All calls in the step settled<br/>and tool/result events recorded"]',
|
||||
' allResults["Tool batch settled<br/>recorded tool/result events complete"]',
|
||||
' presentResult["UI completed card<br/>presentResult(args, result)"]',
|
||||
' model --> toolCall',
|
||||
' toolCall --> presentCall',
|
||||
@@ -878,7 +995,7 @@ function renderToolPipeline(): string {
|
||||
' allResults --> context',
|
||||
'```',
|
||||
'',
|
||||
'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`, while `tools/result` observes the immutable outcome after transforms, lossless-JSON validation, and outer error normalization. This lets hooks span tool families without coupling the tools to one policy service. Code Mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, surface denials as binding rejections, and omit `additionalContext` to preserve call/result adjacency.',
|
||||
'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`, while `tools/result` observes the immutable outcome after transforms, lossless-JSON validation, and outer error normalization. This lets hooks span tool families without coupling the tools to one policy service. Code Mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, surface denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency.',
|
||||
'',
|
||||
...maintenanceFooter(maintenance),
|
||||
].join('\n')
|
||||
@@ -897,14 +1014,14 @@ function renderSnapshotReplay(): string {
|
||||
' participant Workspace',
|
||||
' participant Replay as llm-replay adapter',
|
||||
' participant ACP as acp-agent subprocess',
|
||||
' participant Golden as stdout golden',
|
||||
' participant Expected as stdout expected output',
|
||||
' Recorder->>Fixture: session.jsonl + workspace inputs',
|
||||
' Fixture->>Workspace: seed files and hook configs',
|
||||
' Fixture->>Replay: recorded StreamChunk script',
|
||||
` Replay->>ACP: deterministic ${mermaidCode('llm/stream')} chunks`,
|
||||
' ACP->>Workspace: bash, fs, and hook side effects',
|
||||
' ACP->>Golden: normalized sessionUpdate stream',
|
||||
' Golden-->>ACP: diff must be empty',
|
||||
' ACP->>Expected: normalized sessionUpdate stream',
|
||||
' Expected-->>ACP: diff must be empty',
|
||||
'```',
|
||||
'',
|
||||
'The fs and hook snapshot matrix is valuable because it proves world state, hook decisions, and failed tool-card rendering, not just that replay returns text.',
|
||||
@@ -930,8 +1047,8 @@ 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/coding-agent/composition.md': 'coding-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',
|
||||
'examples/acp-agent/composition.md': 'acp-agent app composition',
|
||||
'docs/event-producer-consumer.md': 'event producer/consumer matrix',
|
||||
@@ -941,8 +1058,8 @@ function renderIndex(docs: GraphDoc[]): string {
|
||||
}
|
||||
const modes: Record<string, string> = {
|
||||
'docs/capability-seams.md': 'hybrid generated',
|
||||
'examples/echo-agent/composition.md': 'hybrid generated',
|
||||
'examples/coding-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',
|
||||
'examples/acp-agent/composition.md': 'hybrid generated',
|
||||
'docs/event-producer-consumer.md': 'hybrid generated',
|
||||
@@ -963,7 +1080,7 @@ function renderIndex(docs: GraphDoc[]): string {
|
||||
...generatedHeader('Documentation Graph Index'),
|
||||
'These diagrams are the relationship layer above the generated catalogs. Use them to navigate package topology, capability seams, event flow, model-facing tools, app composition, and runtime lifecycle paths. Exact signatures and type shapes still live in the generated [events](cordis-catalog/events.md) / [services](cordis-catalog/services.md) catalogs, [tool-catalog.md](tool-catalog.md), and [core-data-structures/](core-data-structures/core.md).',
|
||||
'',
|
||||
'The process decision behind this index is recorded in [the documentation graph RFC](rfc/implemented/process/2026-07-03-documentation-graph-atlas.md).',
|
||||
'The process decision behind this index is recorded in [the documentation graph Agent Note](../.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.md).',
|
||||
'',
|
||||
'| Graph | Mode |',
|
||||
'| --- | --- |',
|
||||
|
||||
@@ -21,18 +21,22 @@ const GROUP_ORDER = [
|
||||
'util',
|
||||
'llm',
|
||||
'core',
|
||||
'goal',
|
||||
'bash',
|
||||
'fs',
|
||||
'skill',
|
||||
'compact',
|
||||
'subagent',
|
||||
'web',
|
||||
'spill',
|
||||
'timeout',
|
||||
'todo',
|
||||
'plan',
|
||||
'cordis',
|
||||
'hooks',
|
||||
'session-persistence',
|
||||
'session-query',
|
||||
'session-title',
|
||||
'support',
|
||||
'ui',
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Generate `docs/persistence-catalog.md` from every `SessionEventMap` merge and
|
||||
* the owning `SurfaceEventType` union. This is the durable-record vocabulary,
|
||||
* not the live Cordis bus. Event declarations must be unique, explicitly typed,
|
||||
* the owning event-envelope types. This is the durable-record vocabulary, not
|
||||
* the live Cordis bus. Event declarations must be unique, explicitly typed,
|
||||
* documented, inheritance-free, and free of Cordis-only `@mode` tags; every
|
||||
* surface-union member must resolve to one. `--check` verifies the artifact.
|
||||
*/
|
||||
@@ -14,13 +14,23 @@ import { parseJsDoc, pointer, rawJsDoc, reportViolations } from './jsdoc.ts'
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'docs/persistence-catalog.md'
|
||||
|
||||
/** The fenced-block info string for generated payload blocks (skipped by
|
||||
* doc-typecheck, since a bare payload fragment is not standalone-compilable). */
|
||||
/** The fenced-block info string for generated declaration blocks (skipped by
|
||||
* doc-typecheck, since their imported types are not standalone-compilable). */
|
||||
const FENCE = 'ts persistence-catalog'
|
||||
|
||||
/** The package whose module id plugin merges augment (`declare module '…'`). */
|
||||
const SESSION_MODULE = '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Event-envelope declarations rendered before the per-event vocabulary. */
|
||||
const EVENT_ENVELOPE_TYPE_NAMES = [
|
||||
'SessionEventType',
|
||||
'SurfaceEventType',
|
||||
'SurfaceOp',
|
||||
'SessionEvent',
|
||||
] as const
|
||||
|
||||
type EventEnvelopeTypeName = typeof EVENT_ENVELOPE_TYPE_NAMES[number]
|
||||
|
||||
/** Primary core-data-structures page for linked payload types. */
|
||||
const LINK_MAP: Record<string, string> = {
|
||||
CallId: 'core.md',
|
||||
@@ -31,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. */
|
||||
@@ -41,6 +56,8 @@ export interface LogEventEntry {
|
||||
scope: string
|
||||
/** Payload type text (the member's type annotation, whitespace-collapsed). */
|
||||
payload: string
|
||||
/** Source member declaration and complete JSDoc, dedented from its container. */
|
||||
declaration: string
|
||||
/** Description prose (the member's JSDoc), one line per paragraph. */
|
||||
doc: string
|
||||
/** Source pointer `packages/…/file.ts:line` of the declaration. */
|
||||
@@ -53,6 +70,16 @@ export interface AnnotatedLogEventEntry extends LogEventEntry {
|
||||
surface: boolean
|
||||
}
|
||||
|
||||
/** One owning event-envelope declaration pasted into the generated catalog. */
|
||||
export interface EventEnvelopeTypeEntry {
|
||||
/** Exported declaration name. */
|
||||
name: EventEnvelopeTypeName
|
||||
/** Verbatim type declaration, including its complete leading JSDoc. */
|
||||
declaration: string
|
||||
/** Source pointer `packages/…/file.ts:line` of the declaration. */
|
||||
source: string
|
||||
}
|
||||
|
||||
const printer = ts.createPrinter({ removeComments: true })
|
||||
|
||||
/**
|
||||
@@ -67,6 +94,24 @@ function payloadText(type: ts.TypeNode, sf: ts.SourceFile): string {
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a declaration from its leading JSDoc through its closing token while
|
||||
* removing only the indentation imposed by its containing interface/module.
|
||||
*/
|
||||
function declarationText(text: string, sf: ts.SourceFile, node: ts.Node): string {
|
||||
const raw = rawJsDoc(text, node)
|
||||
const nodeStart = node.getStart(sf)
|
||||
const start = raw ? text.lastIndexOf(raw, nodeStart) : nodeStart
|
||||
const { line } = sf.getLineAndCharacterOfPosition(start)
|
||||
const lineStart = sf.getPositionOfLineAndCharacter(line, 0)
|
||||
const indent = text.slice(lineStart, start)
|
||||
return text.slice(lineStart, node.end)
|
||||
.split('\n')
|
||||
.map(lineText => lineText.startsWith(indent) ? lineText.slice(indent.length) : lineText)
|
||||
.join('\n')
|
||||
.trimEnd()
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `interface SessionEventMap` declaration in a source file: the owning
|
||||
* top-level declaration (in `@deepseek-ai/dsh-session`) and any declaration
|
||||
@@ -177,7 +222,8 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] {
|
||||
if (!doc) {
|
||||
violations.push(`${where} has no description prose. Say what the event records and what its payload means — the JSDoc becomes the catalog entry.`)
|
||||
}
|
||||
entries.push({ name, scope: name.split('/')[0] ?? name, payload, doc, source: src })
|
||||
const declaration = declarationText(text, sf, member)
|
||||
entries.push({ name, scope: name.split('/')[0] ?? name, payload, declaration, doc, source: src })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,6 +231,51 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] {
|
||||
return entries
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the exported declarations that compose the persisted event envelope,
|
||||
* preserving their source JSDoc and declaration text.
|
||||
*/
|
||||
export function collectEventEnvelopeTypes(scanRoot: string = root): EventEnvelopeTypeEntry[] {
|
||||
const found = new Map<EventEnvelopeTypeName, EventEnvelopeTypeEntry>()
|
||||
const violations: string[] = []
|
||||
const wanted = new Set<string>(EVENT_ENVELOPE_TYPE_NAMES)
|
||||
for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) {
|
||||
const abs = resolve(scanRoot, rel)
|
||||
const text = readFileSync(abs, 'utf8')
|
||||
if (!EVENT_ENVELOPE_TYPE_NAMES.some(name => text.includes(name))) continue
|
||||
if (packageNameFor(rel, scanRoot) !== SESSION_MODULE) continue
|
||||
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
|
||||
for (const stmt of sf.statements) {
|
||||
if (!ts.isTypeAliasDeclaration(stmt) || !wanted.has(stmt.name.text)) continue
|
||||
const name = stmt.name.text as EventEnvelopeTypeName
|
||||
const src = pointer(rel, sf, stmt)
|
||||
const where = `event-envelope type '${name}' (${src})`
|
||||
const prior = found.get(name)
|
||||
if (prior) {
|
||||
violations.push(`${where} is already declared at ${prior.source}; the persisted envelope type has exactly one owner.`)
|
||||
continue
|
||||
}
|
||||
if (!(stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false)) {
|
||||
violations.push(`${where} is not exported.`)
|
||||
}
|
||||
const { doc, hasMode } = parseJsDoc(rawJsDoc(text, stmt))
|
||||
if (hasMode) violations.push(`${where} carries an @mode tag, but a persisted type has no dispatch mode.`)
|
||||
if (!doc) violations.push(`${where} has no description prose. The full JSDoc is part of the generated catalog.`)
|
||||
found.set(name, { name, declaration: declarationText(text, sf, stmt), source: src })
|
||||
}
|
||||
}
|
||||
const missing = EVENT_ENVELOPE_TYPE_NAMES.filter(name => !found.has(name))
|
||||
if (missing.length > 0) {
|
||||
violations.push(`missing event-envelope declaration(s): ${missing.join(', ')}.`)
|
||||
}
|
||||
reportViolations('gen-persistence-catalog', violations)
|
||||
return EVENT_ENVELOPE_TYPE_NAMES.map((name) => {
|
||||
const entry = found.get(name)
|
||||
if (!entry) throw new Error(`gen-persistence-catalog: missing checked event-envelope declaration '${name}'.`)
|
||||
return entry
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `SurfaceEventType` union — the surface-eligible subset of event
|
||||
* types — from source. Hard-errors when the alias is missing, declared more
|
||||
@@ -246,8 +337,7 @@ function typeLinks(payload: string): string {
|
||||
/** Render one log event entry. */
|
||||
function renderEvent(e: AnnotatedLogEventEntry): string[] {
|
||||
const out = [`#### \`${e.name}\` — ${e.surface ? 'surface' : 'log-only'}`, '']
|
||||
if (e.doc) out.push(e.doc, '')
|
||||
out.push('```' + FENCE, `'${e.name}': ${e.payload}`, '```', '')
|
||||
out.push('```' + FENCE, e.declaration, '```', '')
|
||||
const links = typeLinks(e.payload)
|
||||
if (links) out.push(links, '')
|
||||
out.push(`Source: [\`${e.source}\`](../${e.source.split(':')[0]})`, '')
|
||||
@@ -255,18 +345,26 @@ function renderEvent(e: AnnotatedLogEventEntry): string[] {
|
||||
}
|
||||
|
||||
/** Render the full catalog (pure, deterministic given the collected inputs). */
|
||||
export function render(events: AnnotatedLogEventEntry[]): string {
|
||||
export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnvelopeTypeEntry[]): string {
|
||||
const lines: string[] = [
|
||||
'<!-- Generated by scripts/gen-persistence-catalog.ts — do not edit by hand.',
|
||||
' Run `pnpm run gen-persistence-catalog` to regenerate. -->',
|
||||
'',
|
||||
'# Persistence Log Event Catalog',
|
||||
'# Session Persistence Event Catalog',
|
||||
'',
|
||||
'Every event type that can appear in a session\'s durable event log: each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with the payload it carries, its surface badge, and the declaration it comes from. It complements [session.md](core-data-structures/session.md) (the `SessionEvent` envelope, surface list, and `deriveMessages()` projection), [persistence.md](core-data-structures/persistence.md) (how the log is made durable), and the [cordis events catalog](cordis-catalog/events.md) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).',
|
||||
'Every event type that can appear in a session\'s durable event log: the complete persisted `SessionEvent` envelope and each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with source JSDoc, full payload declaration, surface badge, and declaration site. It complements [session.md](core-data-structures/session.md) (surface ordering and the `deriveMessages()` projection), [persistence.md](core-data-structures/persistence.md) (how the log is made durable), and the [cordis events catalog](cordis-catalog/events.md) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).',
|
||||
'',
|
||||
'This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Payload blocks use a `ts persistence-catalog` fence (skipped by doc-typecheck, since a bare payload fragment is not standalone-compilable). Type names in a payload link to the page that documents them. See [the persistence-log-catalog RFC](rfc/implemented/process/2026-07-04-persistence-log-catalog.md).',
|
||||
'This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks retain the source declaration and nested property JSDoc, removing only the indentation imposed by a containing interface/module, and use a `ts persistence-catalog` fence (skipped by doc-typecheck because declarations reference types from their owning modules). Type names in a payload link to the page that documents them. See [the persistence-log-catalog Agent Note](../.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.md).',
|
||||
'',
|
||||
'The on-disk envelope around every payload is `SessionEvent` — `type`, monotonic `seq`, epoch-ms `time`, the `data` documented here, plus `surfaceOp`/`sourceEventSeqs` on **surface** events only ([envelope](core-data-structures/session.md#sessioneventt--one-log-entry)). **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](core-data-structures/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.',
|
||||
'The envelope declarations below compose each event\'s `type`, monotonic `seq`, epoch-ms `time`, `data`, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](core-data-structures/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.',
|
||||
'',
|
||||
'## Event envelope',
|
||||
'',
|
||||
'```' + FENCE,
|
||||
envelopeTypes.map(entry => entry.declaration).join('\n\n'),
|
||||
'```',
|
||||
'',
|
||||
`Sources: ${envelopeTypes.map(entry => `[\`${entry.source}\`](../${entry.source.split(':')[0]})`).join(' · ')}`,
|
||||
'',
|
||||
'## Events',
|
||||
'',
|
||||
@@ -285,7 +383,7 @@ export function render(events: AnnotatedLogEventEntry[]): string {
|
||||
* is stale. Guarded behind an entry-point check so importing this module for
|
||||
* tests neither regenerates the committed file nor calls process.exit. */
|
||||
function main(): void {
|
||||
const content = render(annotateSurface(collectLogEvents(), collectSurfaceEventTypes()))
|
||||
const content = render(annotateSurface(collectLogEvents(), collectSurfaceEventTypes()), collectEventEnvelopeTypes())
|
||||
if (process.argv.includes('--check')) {
|
||||
let committed: string | null = null
|
||||
try {
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* Regenerate `docs/rfc/INDEX.md` — the fully generated RFC index — from the
|
||||
* RFC tree (see [rfc-index.ts](./rfc-index.ts) for the layout contract and
|
||||
* rendering rules). The whole file is generated state; the curated prose lives
|
||||
* in `docs/rfc/README.md`. Freshness is asserted by
|
||||
* `verify-rfc-classification.ts` (a `doc-sync` member), so a stale committed
|
||||
* index fails CI.
|
||||
*
|
||||
* Run: `pnpm run gen-rfc-index`.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { renderIndex, rfcRoot, walkRfcTree } from './rfc-index.ts'
|
||||
|
||||
const { rfcs, errors } = walkRfcTree()
|
||||
if (errors.length > 0) {
|
||||
console.error('gen-rfc-index: refusing to generate from a structurally invalid tree:')
|
||||
for (const e of errors) console.error(` ${e}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const indexPath = resolve(rfcRoot, 'INDEX.md')
|
||||
const next = renderIndex(rfcs)
|
||||
let current: string | undefined
|
||||
try {
|
||||
current = readFileSync(indexPath, 'utf8')
|
||||
} catch {
|
||||
// Missing INDEX.md is the fresh-generation case, not an error: fall through and write it.
|
||||
}
|
||||
if (next === current) {
|
||||
console.log(`gen-rfc-index: docs/rfc/INDEX.md is up to date (${rfcs.length} RFCs).`)
|
||||
} else {
|
||||
writeFileSync(indexPath, next)
|
||||
console.log(`gen-rfc-index: docs/rfc/INDEX.md regenerated (${rfcs.length} RFCs).`)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -3,38 +3,104 @@
|
||||
* plugin. Runtime registration is the source of truth for computed schemas;
|
||||
* the manifest is checked against every on-disk `tool-*` package. `--check`
|
||||
* verifies the committed artifact. Rationale and ownership live in
|
||||
* `docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md`.
|
||||
* `.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md`.
|
||||
*/
|
||||
|
||||
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'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
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'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import * as SubagentMock from '@deepseek-ai/dsh-subagent-mock'
|
||||
import type { SubagentProvider } from '@deepseek-ai/dsh-subagent'
|
||||
import SkillService from '@deepseek-ai/dsh-skill'
|
||||
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
|
||||
import TaskService from '@deepseek-ai/dsh-tasks'
|
||||
import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
|
||||
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, '..')
|
||||
const OUT = 'docs/tool-catalog.md'
|
||||
const CATALOG_RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1'
|
||||
|
||||
/**
|
||||
* Minimal bash service for harvesting `dsh-tool-fs-search` schemas. The search
|
||||
* plugin now probes `rg` at registration time, but the generated catalog must
|
||||
* remain independent of the host PATH and never execute a real search.
|
||||
*/
|
||||
class CatalogSearchBashExecutor extends BashExecutor {
|
||||
override resolve(request: BashExecRequest): BashExecSpec {
|
||||
return {
|
||||
command: request.command,
|
||||
workdir: request.workdir ?? root,
|
||||
timeoutMs: request.timeoutMs ?? 60_000,
|
||||
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
|
||||
signal: request.signal,
|
||||
sandboxPolicy: request.sandboxPolicy,
|
||||
}
|
||||
}
|
||||
|
||||
override run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
if (spec.command !== CATALOG_RG_PROBE_COMMAND) {
|
||||
throw new Error(`gen-tool-catalog: unexpected search bash command during schema harvest: ${spec.command}`)
|
||||
}
|
||||
return Promise.resolve({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
timeoutMs: spec.timeoutMs,
|
||||
stdout: { text: '', truncated: false },
|
||||
stderr: { text: '', truncated: false },
|
||||
})
|
||||
}
|
||||
|
||||
override start(): BashProcess {
|
||||
throw new Error('gen-tool-catalog: search schema harvest must not start background processes')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: true, depthLimit: true, toolFilter: true, persona: true },
|
||||
inheritsParentContext: false,
|
||||
start: () => Promise.reject(new Error('tool-catalog provider cannot start a child')),
|
||||
}
|
||||
ctx.subagents.registerProvider(provider)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool package plus its hand-maintained boot recipe. The caller mounts the
|
||||
@@ -105,20 +171,32 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
toolsConfig: { mode: 'code' },
|
||||
async mount() {},
|
||||
note:
|
||||
'Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode RFC). 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.',
|
||||
'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',
|
||||
source: 'packages/bash/tool-bash/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.bash'],
|
||||
writes: ['tool/call', 'tool/result', 'context/message via agent.inject() for background completion notices'],
|
||||
requires: ['ctx.tools', 'ctx.bash', 'ctx.tasks at call time for run_in_background'],
|
||||
writes: ['tool/call', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(LocalBashExecutor)
|
||||
await ctx.plugin(ToolBash)
|
||||
},
|
||||
note:
|
||||
'The bash/bash_output/bash_kill tools are model-facing consumers of the bash executor seam.',
|
||||
'The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-cordis',
|
||||
@@ -130,7 +208,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
await ctx.plugin(ToolCordis)
|
||||
},
|
||||
note:
|
||||
'Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; the request-header ToolsDelta logs those tool-set changes.',
|
||||
'Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; a full changed request header logs those tool-set changes.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-fs',
|
||||
@@ -147,6 +225,67 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
note:
|
||||
'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-fs-search',
|
||||
dir: 'tool-fs-search',
|
||||
source: 'packages/fs/tool-fs-search/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.bash', 'ctx.systemPrompt'],
|
||||
writes: ['tool/call', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
// The tools inject `bash` (search executes fixed `rg` commands through
|
||||
// the executor seam, not ctx.fs). Use a catalog-only executor so the
|
||||
// registration-time `rg` probe stays deterministic and the generator
|
||||
// never depends on the host PATH. `ctx.spillStore` is optional (read via
|
||||
// ctx.get) and does not affect the schemas, so no spill backend is mounted.
|
||||
await ctx.plugin(CatalogSearchBashExecutor)
|
||||
await ctx.plugin(ToolFsSearch)
|
||||
},
|
||||
note:
|
||||
'glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.',
|
||||
},
|
||||
{
|
||||
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',
|
||||
@@ -171,12 +310,24 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
shippedNames: ['subagent', 'subagent_fork'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(SubagentService)
|
||||
// Register a scripted provider under the name the tool delegates to.
|
||||
await ctx.plugin(SubagentMock, { name: 'mock' })
|
||||
registerCatalogSubagentProvider(ctx, 'mock')
|
||||
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/coding-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',
|
||||
dir: 'tool-tasks',
|
||||
source: 'packages/tasks/tool-tasks/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.tasks', 'ctx.systemPrompt'],
|
||||
writes: ['tool/call', 'tool/result', 'context/message via agent.inject() for background completion notices'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(TaskService)
|
||||
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()`.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-todo',
|
||||
@@ -201,7 +352,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
// subagent provider to satisfy it. The schema does not depend on which
|
||||
// provider backs the engine.
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(SubagentMock, { name: 'mock' })
|
||||
registerCatalogSubagentProvider(ctx, 'mock')
|
||||
await ctx.plugin(VmWorkflowEngine, { provider: 'mock' })
|
||||
await ctx.plugin(ToolWorkflow)
|
||||
},
|
||||
@@ -324,7 +475,7 @@ export function render(catalog: ToolCatalog): string {
|
||||
'',
|
||||
'Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the cordis [events](cordis-catalog/events.md) & [services](cordis-catalog/services.md) catalogs (the wiring a plugin listens to and calls) and [core-data-structures/](core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered.',
|
||||
'',
|
||||
'This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator\'s boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog RFC](rfc/implemented/process/2026-07-02-tool-schema-catalog.md).',
|
||||
'This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator\'s boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog Agent Note](../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md).',
|
||||
'',
|
||||
'Scope: shipped product tools under `packages/*/tool-*`, each booted with its DEFAULT config. The registered tool NAME can be a load-time config (e.g. `tool-subagent`\'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog\'s packages-only scope.',
|
||||
'',
|
||||
|
||||
284
scripts/install.sh
Executable file
284
scripts/install.sh
Executable file
@@ -0,0 +1,284 @@
|
||||
#!/bin/sh
|
||||
# dsh one-line installer.
|
||||
#
|
||||
# curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh
|
||||
#
|
||||
# It clones the harness to ~/.dsh/source, checks host dependencies (git, Node,
|
||||
# pnpm) and offers to install a missing pnpm, runs `pnpm install` (no build —
|
||||
# the `bin/dsh` launcher runs the TypeScript source through the repo's own tsx),
|
||||
# symlinks `dsh` onto PATH, records your API credentials in the Harness home
|
||||
# (`~/.dsh`) dsh reads at boot, and drops you into `dsh`.
|
||||
#
|
||||
# When run from inside an existing checkout (e.g. `sh scripts/install.sh` rather
|
||||
# than `curl ... | sh`) it reuses that checkout and skips the clone/update, leaving
|
||||
# the working tree untouched; DSH_REF is ignored in that mode. Setting DSH_SOURCE
|
||||
# to a different directory opts back into the normal clone/update path.
|
||||
#
|
||||
# When run through `curl | sh` the script text arrives on stdin, so every
|
||||
# prompt and the final launch read the controlling terminal (/dev/tty) directly;
|
||||
# with no terminal the script prints the manual next steps instead.
|
||||
#
|
||||
# Overridable via environment:
|
||||
# DSH_REF branch or tag to clone/checkout (default: master)
|
||||
# DSH_REPO clone URL (default: the GitHub repo)
|
||||
# DSH_SOURCE checkout location (default: ~/.dsh/source)
|
||||
# DSH_BIN_DIR directory the `dsh` symlink lands in (default: ~/.local/bin)
|
||||
# DSH_HOME Harness home holding the personal config (default: ~/.dsh)
|
||||
# FIXME(install-ts): Move the post-checkout workflow into a tested TypeScript
|
||||
# entrypoint; keep this POSIX shell file as the curl/source bootstrap.
|
||||
set -eu
|
||||
|
||||
DSH_REF=${DSH_REF:-master}
|
||||
DSH_REPO=${DSH_REPO:-https://github.com/deepseek-harness/deepseek-harness.git}
|
||||
# Remember whether the caller pinned a source location before defaulting it, so
|
||||
# in-repo detection only repoints an unset DSH_SOURCE.
|
||||
if [ -n "${DSH_SOURCE:-}" ]; then DSH_SOURCE_EXPLICIT=1; else DSH_SOURCE_EXPLICIT=0; fi
|
||||
DSH_SOURCE=${DSH_SOURCE:-$HOME/.dsh/source}
|
||||
DSH_BIN_DIR=${DSH_BIN_DIR:-$HOME/.local/bin}
|
||||
|
||||
# --- in-repo detection ---------------------------------------------------------
|
||||
# Under `curl ... | sh` the script text arrives on stdin, so $0 is the shell
|
||||
# name and no file path resolves; running a checked-out copy (`sh
|
||||
# scripts/install.sh`) makes $0 the script file. When $0 is a readable file whose
|
||||
# parent is a scripts/ dir inside a real dsh checkout (bin/dsh launcher present),
|
||||
# reuse that checkout and skip the clone. An explicit DSH_SOURCE pointing
|
||||
# elsewhere opts back into the clone/update path.
|
||||
IN_REPO=0
|
||||
if [ -f "$0" ]; then
|
||||
_self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd -P) || _self_dir=''
|
||||
if [ -n "$_self_dir" ]; then
|
||||
_repo_root=$(dirname -- "$_self_dir")
|
||||
if [ "$(basename -- "$_self_dir")" = scripts ] \
|
||||
&& [ -x "$_repo_root/bin/dsh" ] && [ -f "$_repo_root/scripts/install.sh" ]; then
|
||||
if [ "$DSH_SOURCE_EXPLICIT" = 0 ] || [ "$DSH_SOURCE" = "$_repo_root" ]; then
|
||||
IN_REPO=1
|
||||
DSH_SOURCE=$_repo_root
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- terminal-aware prompting --------------------------------------------------
|
||||
# stdin is the piped script, so read the controlling terminal for input.
|
||||
if { true </dev/tty; } 2>/dev/null; then
|
||||
HAS_TTY=1
|
||||
# Restore terminal echo on exit or interrupt: ask_secret disables echo between
|
||||
# its stty toggles, and dash (a common `sh`) does not run an EXIT trap when the
|
||||
# shell is killed by a signal, so the fatal signals need their own handler. A
|
||||
# successful run ends in exec, which replaces this process and drops the traps.
|
||||
trap 'stty echo </dev/tty 2>/dev/null || true' EXIT
|
||||
trap 'stty echo </dev/tty 2>/dev/null || true; exit 130' INT TERM HUP
|
||||
else
|
||||
HAS_TTY=0
|
||||
fi
|
||||
|
||||
# Colour only when writing to a terminal.
|
||||
if [ -t 1 ]; then
|
||||
B=$(printf '\033[1m'); DIM=$(printf '\033[2m'); RED=$(printf '\033[31m')
|
||||
GRN=$(printf '\033[32m'); YEL=$(printf '\033[33m'); RST=$(printf '\033[0m')
|
||||
else
|
||||
B=''; DIM=''; RED=''; GRN=''; YEL=''; RST=''
|
||||
fi
|
||||
|
||||
info() { printf '%s==>%s %s\n' "$GRN" "$RST" "$1"; }
|
||||
step() { printf '\n%s==>%s %s%s%s\n' "$GRN" "$RST" "$B" "$1" "$RST"; }
|
||||
warn() { printf '%s warn%s %s\n' "$YEL" "$RST" "$1" >&2; }
|
||||
die() { printf '%serror%s %s\n' "$RED" "$RST" "$1" >&2; exit 1; }
|
||||
|
||||
# ask PROMPT [DEFAULT] -> answer on stdout (plain-text line).
|
||||
ask() {
|
||||
[ "$HAS_TTY" = 1 ] || die "no terminal available for input; re-run in an interactive shell"
|
||||
printf '%s%s%s ' "$B" "$1" "$RST" >/dev/tty
|
||||
IFS= read -r _ans </dev/tty || _ans=''
|
||||
[ -n "$_ans" ] || _ans=${2:-}
|
||||
printf '%s' "$_ans"
|
||||
}
|
||||
|
||||
# ask_secret PROMPT -> answer on stdout, with terminal echo suppressed.
|
||||
ask_secret() {
|
||||
[ "$HAS_TTY" = 1 ] || die "no terminal available for input; re-run in an interactive shell"
|
||||
printf '%s%s%s ' "$B" "$1" "$RST" >/dev/tty
|
||||
stty -echo </dev/tty 2>/dev/null || true
|
||||
IFS= read -r _sec </dev/tty || _sec=''
|
||||
stty echo </dev/tty 2>/dev/null || true
|
||||
printf '\n' >/dev/tty
|
||||
printf '%s' "$_sec"
|
||||
}
|
||||
|
||||
# confirm PROMPT [Y] -> exit 0 on yes. Default is no unless second arg is "Y".
|
||||
confirm() {
|
||||
_def=${2:-N}
|
||||
if [ "$HAS_TTY" != 1 ]; then
|
||||
[ "$_def" = Y ] # non-interactive: take the default
|
||||
return
|
||||
fi
|
||||
if [ "$_def" = Y ]; then _hint='[Y/n]'; else _hint='[y/N]'; fi
|
||||
printf '%s%s%s %s ' "$B" "$1" "$RST" "$_hint" >/dev/tty
|
||||
IFS= read -r _r </dev/tty || _r=''
|
||||
[ -n "$_r" ] || _r=$_def
|
||||
case "$_r" in [yY]|[yY][eE][sS]) return 0 ;; *) return 1 ;; esac
|
||||
}
|
||||
|
||||
printf '%s\n' "${B}DeepSeek Harness — dsh installer${RST}"
|
||||
printf '%ssource %s @ %s%s\n' "$DIM" "$DSH_SOURCE" "$DSH_REF" "$RST"
|
||||
|
||||
# --- 1. dependency check -------------------------------------------------------
|
||||
step "Checking dependencies"
|
||||
|
||||
command -v git >/dev/null 2>&1 || die "git is required but not found. Install git, then re-run."
|
||||
info "git ... ok"
|
||||
|
||||
# Node ^22.19.0 || >=24.0.0 (see the root package.json "engines" field).
|
||||
node_ok() {
|
||||
command -v node >/dev/null 2>&1 || return 1
|
||||
_v=$(node -v 2>/dev/null) || return 1
|
||||
_v=${_v#v}
|
||||
_major=${_v%%.*}
|
||||
_rest=${_v#*.}
|
||||
_minor=${_rest%%.*}
|
||||
case "$_major" in ''|*[!0-9]*) return 1 ;; esac
|
||||
case "$_minor" in ''|*[!0-9]*) _minor=0 ;; esac
|
||||
[ "$_major" -ge 24 ] && return 0
|
||||
[ "$_major" -eq 22 ] && [ "$_minor" -ge 19 ] && return 0
|
||||
return 1
|
||||
}
|
||||
if node_ok; then
|
||||
info "node $(node -v) ... ok"
|
||||
else
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
die "Node $(node -v) is unsupported. dsh needs ^22.19.0 || >=24.0.0 — upgrade Node, then re-run."
|
||||
fi
|
||||
die "Node is required but not found. Install Node ^22.19.0 || >=24, then re-run."
|
||||
fi
|
||||
|
||||
# pnpm is the only dependency we offer to install for you.
|
||||
if command -v pnpm >/dev/null 2>&1; then
|
||||
info "pnpm $(pnpm --version 2>/dev/null) ... ok"
|
||||
else
|
||||
warn "pnpm is not installed."
|
||||
if confirm "Install pnpm now?" Y; then
|
||||
if command -v corepack >/dev/null 2>&1 && corepack enable pnpm >/dev/null 2>&1; then
|
||||
info "enabled pnpm via corepack"
|
||||
elif command -v npm >/dev/null 2>&1 && npm install -g pnpm >/dev/null 2>&1; then
|
||||
info "installed pnpm via npm"
|
||||
else
|
||||
die "could not install pnpm automatically. Install it (https://pnpm.io/installation), then re-run."
|
||||
fi
|
||||
command -v pnpm >/dev/null 2>&1 || die "pnpm still not on PATH after install. Open a new shell, then re-run."
|
||||
else
|
||||
die "pnpm is required. Install it (https://pnpm.io/installation), then re-run."
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 2. clone (or update) the source ------------------------------------------
|
||||
if [ "$IN_REPO" = 1 ]; then
|
||||
step "Using existing checkout at $DSH_SOURCE"
|
||||
info "running from inside the repo — skipping clone (DSH_REF ignored, working tree left untouched)"
|
||||
else
|
||||
step "Fetching source into $DSH_SOURCE"
|
||||
if [ -d "$DSH_SOURCE/.git" ]; then
|
||||
info "existing checkout found — updating"
|
||||
git -C "$DSH_SOURCE" fetch --depth 1 origin "$DSH_REF"
|
||||
# Reset the checkout to the freshly fetched tip. FETCH_HEAD (not
|
||||
# origin/<ref>) so this resolves for a tag as well as a branch, and -B makes
|
||||
# the re-run idempotent whether or not DSH_REF changed since the last install.
|
||||
git -C "$DSH_SOURCE" checkout -q -B "$DSH_REF" FETCH_HEAD
|
||||
else
|
||||
mkdir -p "$(dirname "$DSH_SOURCE")"
|
||||
git clone --depth 1 --branch "$DSH_REF" "$DSH_REPO" "$DSH_SOURCE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 3. install dependencies (no build; the launcher runs from source) --------
|
||||
step "Installing dependencies with pnpm (this can take a while)"
|
||||
( cd "$DSH_SOURCE" && pnpm install )
|
||||
|
||||
[ -x "$DSH_SOURCE/bin/dsh" ] || die "launcher $DSH_SOURCE/bin/dsh missing after install — is DSH_REF a branch that ships apps/cli?"
|
||||
|
||||
# --- 4. put `dsh` on PATH ------------------------------------------------------
|
||||
step "Linking dsh into $DSH_BIN_DIR"
|
||||
mkdir -p "$DSH_BIN_DIR"
|
||||
ln -sf "$DSH_SOURCE/bin/dsh" "$DSH_BIN_DIR/dsh"
|
||||
info "linked $DSH_BIN_DIR/dsh -> $DSH_SOURCE/bin/dsh"
|
||||
|
||||
case ":$PATH:" in
|
||||
*":$DSH_BIN_DIR:"*) ON_PATH=1 ;;
|
||||
*) ON_PATH=0 ;;
|
||||
esac
|
||||
if [ "$ON_PATH" = 0 ]; then
|
||||
warn "$DSH_BIN_DIR is not on your PATH."
|
||||
_line="export PATH=\"$DSH_BIN_DIR:\$PATH\""
|
||||
_rc=''
|
||||
_sh=${SHELL:-} # SHELL may be unset; word-removal on an unset var trips set -u under dash.
|
||||
case "${_sh##*/}" in
|
||||
zsh) _rc="$HOME/.zshrc" ;;
|
||||
bash) _rc="$HOME/.bashrc" ;;
|
||||
esac
|
||||
if [ -n "$_rc" ] && [ -f "$_rc" ] && grep -qF "$_line" "$_rc" 2>/dev/null; then
|
||||
info "$_rc already exports $DSH_BIN_DIR — open a new shell to pick it up"
|
||||
elif [ -n "$_rc" ] && confirm "Add it to $_rc?" Y; then
|
||||
printf '\n# Added by the dsh installer\n%s\n' "$_line" >>"$_rc"
|
||||
info "updated $_rc — run 'source $_rc' or open a new shell to pick it up"
|
||||
else
|
||||
warn "add this line to your shell profile yourself:"
|
||||
printf ' %s\n' "$_line"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 5. credentials ------------------------------------------------------------
|
||||
# Mirror app-boot's resolveDshHome precedence ($DSH_HOME, else ~/.dsh) so creds land where dsh reads them.
|
||||
if [ -n "${DSH_HOME:-}" ]; then
|
||||
CONF="$DSH_HOME"
|
||||
else
|
||||
CONF="$HOME/.dsh"
|
||||
fi
|
||||
ENV_FILE="$CONF/.env"
|
||||
|
||||
step "Configuring credentials"
|
||||
if [ -f "$ENV_FILE" ] && grep -q '^DEEPSEEK_API_KEY=' "$ENV_FILE" 2>/dev/null; then
|
||||
info "DEEPSEEK_API_KEY already set in $ENV_FILE"
|
||||
if ! confirm "Replace it?" N; then
|
||||
SKIP_CREDS=1
|
||||
fi
|
||||
fi
|
||||
if [ "${SKIP_CREDS:-0}" != 1 ]; then
|
||||
if [ "$HAS_TTY" = 1 ]; then
|
||||
API_KEY=$(ask_secret "DeepSeek API key (input hidden):")
|
||||
if [ -z "$API_KEY" ]; then
|
||||
warn "no key entered — skipping. Set DEEPSEEK_API_KEY in $ENV_FILE before using dsh."
|
||||
else
|
||||
BASE_URL=$(ask "DeepSeek base URL (optional, Enter to skip):")
|
||||
mkdir -p "$CONF"
|
||||
# The installer owns exactly the two DEEPSEEK_* lines; any other lines the
|
||||
# user keeps in this .env are preserved. The rewrite happens in a subshell
|
||||
# so umask 077 (which closes the create-time permission race) does not leak
|
||||
# into the exec'd dsh, and lands atomically via a same-dir temp + mv.
|
||||
_tmp="$ENV_FILE.dsh.$$"
|
||||
(
|
||||
umask 077
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
grep -v -e '^DEEPSEEK_API_KEY=' -e '^DEEPSEEK_BASE_URL=' "$ENV_FILE" >"$_tmp" || true
|
||||
else
|
||||
: >"$_tmp"
|
||||
fi
|
||||
printf 'DEEPSEEK_API_KEY=%s\n' "$API_KEY" >>"$_tmp"
|
||||
if [ -n "$BASE_URL" ]; then printf 'DEEPSEEK_BASE_URL=%s\n' "$BASE_URL" >>"$_tmp"; fi
|
||||
)
|
||||
mv "$_tmp" "$ENV_FILE"
|
||||
chmod 600 "$ENV_FILE" 2>/dev/null || true
|
||||
info "wrote $ENV_FILE"
|
||||
fi
|
||||
else
|
||||
warn "no terminal for credential input — set DEEPSEEK_API_KEY in $ENV_FILE before using dsh."
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 6. launch -----------------------------------------------------------------
|
||||
step "Done"
|
||||
if [ "$HAS_TTY" = 1 ]; then
|
||||
info "launching dsh — run 'dsh' anytime to start again"
|
||||
exec "$DSH_BIN_DIR/dsh" </dev/tty
|
||||
else
|
||||
info "install complete. Start it with:"
|
||||
printf ' %s\n' "$DSH_BIN_DIR/dsh"
|
||||
fi
|
||||
55
scripts/md-fences.ts
Normal file
55
scripts/md-fences.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Shared fenced-code-block extractor for the Markdown doc gates
|
||||
* (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.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
/** One extracted fenced block, classified by the caller's `classify`. */
|
||||
export interface Fence<K> {
|
||||
/** 1-based line of the opening fence. */
|
||||
line: number
|
||||
kind: K
|
||||
code: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract every fenced block of `absPath` whose info string `classify` maps
|
||||
* to a kind. Blocks classified `null` are skipped (their bodies are still
|
||||
* consumed, so an unrelated fence can never leak into a tracked one).
|
||||
*
|
||||
* @param absPath — absolute path of the Markdown file.
|
||||
* @param classify — info string (trimmed, e.g. `ts ignore-check`) → kind, or
|
||||
* null for fences this gate does not track.
|
||||
* @returns the classified blocks in document order.
|
||||
*/
|
||||
export function extractFences<K>(absPath: string, classify: (info: string) => K | null): Fence<K>[] {
|
||||
const lines = readFileSync(absPath, 'utf8').split('\n')
|
||||
const blocks: Fence<K>[] = []
|
||||
let open: { line: number; kind: K; body: string[] } | null = null
|
||||
let skipping = false
|
||||
|
||||
lines.forEach((raw, i) => {
|
||||
const fence = /^```(\s*)(\S.*)?$/.exec(raw)
|
||||
if (!fence) {
|
||||
if (open) open.body.push(raw)
|
||||
return
|
||||
}
|
||||
if (open) {
|
||||
blocks.push({ line: open.line, kind: open.kind, code: open.body.join('\n') })
|
||||
open = null
|
||||
return
|
||||
}
|
||||
if (skipping) {
|
||||
skipping = false
|
||||
return
|
||||
}
|
||||
const kind = classify((fence[2] ?? '').trim())
|
||||
if (kind !== null) open = { line: i + 1, kind, body: [] }
|
||||
else skipping = true
|
||||
})
|
||||
return blocks
|
||||
}
|
||||
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}`
|
||||
}
|
||||
32
scripts/prepare-ci-bubblewrap.sh
Executable file
32
scripts/prepare-ci-bubblewrap.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Ubuntu's package transaction scans the hosted image's full dpkg database and
|
||||
# runs post-install hooks. CI needs only the signed-archive payload, so pin and
|
||||
# verify that payload before extracting it into the ephemeral runner directory.
|
||||
readonly BUBBLEWRAP_VERSION='0.9.0-1ubuntu0.1'
|
||||
readonly BUBBLEWRAP_SHA256='1b506492bd9c7fd0cdb4f02ac822f1d3e336b0aead5113c1239baf8db5db562a'
|
||||
readonly BUBBLEWRAP_URL="https://archive.ubuntu.com/ubuntu/pool/main/b/bubblewrap/bubblewrap_${BUBBLEWRAP_VERSION}_amd64.deb"
|
||||
|
||||
: "${RUNNER_TEMP:?prepare-ci-bubblewrap requires RUNNER_TEMP}"
|
||||
: "${GITHUB_PATH:?prepare-ci-bubblewrap requires GITHUB_PATH}"
|
||||
|
||||
if [[ "$(uname -s)" != 'Linux' || "$(uname -m)" != 'x86_64' ]]; then
|
||||
echo 'prepare-ci-bubblewrap supports only Linux x86_64 hosted runners' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
archive="${RUNNER_TEMP}/bubblewrap_${BUBBLEWRAP_VERSION}_amd64.deb"
|
||||
root="${RUNNER_TEMP}/dsh-bubblewrap"
|
||||
|
||||
curl --fail --silent --show-error --location --retry 3 --output "$archive" "$BUBBLEWRAP_URL"
|
||||
printf '%s %s\n' "$BUBBLEWRAP_SHA256" "$archive" | sha256sum --check --status
|
||||
mkdir -p "$root"
|
||||
dpkg-deb --extract "$archive" "$root"
|
||||
printf '%s\n' "$root/usr/bin" >> "$GITHUB_PATH"
|
||||
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \
|
||||
|| echo 'apparmor userns knob absent — the functional probe decides'
|
||||
"$root/usr/bin/bwrap" --version
|
||||
"$root/usr/bin/bwrap" --ro-bind / / --dev /dev --proc /proc --die-with-parent -- true
|
||||
echo 'bubblewrap functional probe passed'
|
||||
248
scripts/project-doc-site.spec.ts
Normal file
248
scripts/project-doc-site.spec.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
/** Tests for the documentation website projection adapter. */
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } 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[] = []
|
||||
const repositoryRoot = resolve(import.meta.dirname, '..')
|
||||
|
||||
function unexpectedWebsiteMarkdown(files: readonly string[]): string[] {
|
||||
return files.filter(file => file.endsWith('.md') && file !== 'website/AGENTS.md').sort()
|
||||
}
|
||||
|
||||
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('website source layout', () => {
|
||||
it('rejects Markdown outside the subtree instructions', () => {
|
||||
expect(unexpectedWebsiteMarkdown([
|
||||
'website/AGENTS.md',
|
||||
'website/docs.ts',
|
||||
'website/zh-CN/api/harness/service.md',
|
||||
])).toEqual(['website/zh-CN/api/harness/service.md'])
|
||||
})
|
||||
|
||||
it('contains no tracked or unignored documentation copies', () => {
|
||||
const files = execFileSync(
|
||||
'git',
|
||||
['ls-files', '--cached', '--others', '--exclude-standard', '--', 'website'],
|
||||
{ cwd: repositoryRoot, encoding: 'utf8' },
|
||||
).split('\n').filter(file => file !== '' && existsSync(resolve(repositoryRoot, file)))
|
||||
|
||||
expect(
|
||||
unexpectedWebsiteMarkdown(files),
|
||||
'Keep canonical Markdown under docs/ and publish it through website/docs.ts.',
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
61
scripts/publint-all.spec.ts
Normal file
61
scripts/publint-all.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const repositoryRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
const runner = fileURLToPath(new URL('./publint-all.ts', import.meta.url))
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function fixture(exportPath = './lib/index.js'): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-publint-all-'))
|
||||
roots.push(root)
|
||||
const packageDir = join(root, 'packages/core/probe')
|
||||
mkdirSync(join(packageDir, 'lib'), { recursive: true })
|
||||
writeFileSync(join(packageDir, 'package.json'), `${JSON.stringify({
|
||||
name: '@deepseek-ai/dsh-probe',
|
||||
version: '0.0.1',
|
||||
type: 'module',
|
||||
license: 'MIT',
|
||||
engines: { node: '>=22.19' },
|
||||
sideEffects: false,
|
||||
files: ['lib'],
|
||||
exports: { '.': { default: exportPath } },
|
||||
}, null, 2)}\n`)
|
||||
writeFileSync(join(packageDir, 'README.md'), '# Probe\n')
|
||||
writeFileSync(join(packageDir, 'lib/index.js'), 'export const probe = true\n')
|
||||
writeFileSync(join(packageDir, 'unpublished.js'), 'export const hidden = true\n')
|
||||
return root
|
||||
}
|
||||
|
||||
function run(root: string) {
|
||||
return spawnSync(process.execPath, [
|
||||
'--import', 'tsx', runner,
|
||||
'--packages-root', root,
|
||||
], {
|
||||
cwd: repositoryRoot,
|
||||
encoding: 'utf8',
|
||||
timeout: 5_000,
|
||||
})
|
||||
}
|
||||
|
||||
describe('publint package runner', () => {
|
||||
it('lints recursively declared files from an in-memory publication view', () => {
|
||||
const result = run(fixture())
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stdout).toContain('linting 1 package(s)')
|
||||
expect(result.stdout).toContain('All good!')
|
||||
})
|
||||
|
||||
it('rejects an export that exists in the workspace but is not published', () => {
|
||||
const result = run(fixture('./unpublished.js'))
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stdout).toContain('unpublished.js')
|
||||
})
|
||||
})
|
||||
@@ -1,46 +1,53 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { existsSync, readdirSync } from 'node:fs'
|
||||
/** Run publint over the exact manifest-declared publication view of every package. */
|
||||
|
||||
import {
|
||||
globSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
statSync,
|
||||
} from 'node:fs'
|
||||
import { availableParallelism } from 'node:os'
|
||||
import { resolve } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { dirname, relative, resolve, sep } from 'node:path'
|
||||
import { publint, type Message, type PackFile } from 'publint'
|
||||
import { formatMessage } from 'publint/utils'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY'
|
||||
const repositoryRoot = resolve(import.meta.dirname, '..')
|
||||
const options = parseOptions(process.argv.slice(2))
|
||||
const packagesRoot = resolve(options.get('--packages-root') ?? repositoryRoot)
|
||||
|
||||
// Discover harness packages at packages/<group>/<pkg>; group containers,
|
||||
// examples, and private vendored sources are not package targets.
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const packagesRoot = resolve(root, 'packages')
|
||||
interface PackageTarget {
|
||||
path: string
|
||||
directory: string
|
||||
manifest: PackageManifest
|
||||
}
|
||||
|
||||
// Run publint's JS CLI through the current node, not the .bin shim: the
|
||||
// extensionless shim isn't spawnable on Windows (CVE-2024-27980) and the .cmd
|
||||
// variant needs shell:true, which space-joins args UNESCAPED (DEP0190) and
|
||||
// breaks when the repo path contains spaces. The JS entry is identical on every
|
||||
// platform (`bin` is `./src/cli.js` per publint's package.json).
|
||||
const publintCli = resolve(root, 'node_modules/publint/src/cli.js')
|
||||
interface PackageManifest {
|
||||
name?: string
|
||||
files?: unknown
|
||||
}
|
||||
|
||||
type PublintResult =
|
||||
| { path: string; status: 'passed'; stdout: string; stderr: string }
|
||||
| { path: string; status: 'failed'; stdout: string; stderr: string; message: string }
|
||||
| { path: string; status: 'passed'; messages: Message[]; manifest: Record<string, unknown> }
|
||||
| { path: string; status: 'failed'; messages: Message[]; manifest: Record<string, unknown>; failure?: string }
|
||||
|
||||
function workspacePackages(): string[] {
|
||||
return readdirSync(packagesRoot, { withFileTypes: true })
|
||||
.filter(group => group.isDirectory())
|
||||
.flatMap(group =>
|
||||
readdirSync(resolve(packagesRoot, group.name), { withFileTypes: true })
|
||||
.filter(pkg => pkg.isDirectory())
|
||||
.filter(pkg => existsSync(resolve(packagesRoot, group.name, pkg.name, 'package.json')))
|
||||
.map(pkg => `packages/${group.name}/${pkg.name}`),
|
||||
)
|
||||
function workspacePackages(): PackageTarget[] {
|
||||
return globSync('packages/*/*/package.json', { cwd: packagesRoot })
|
||||
.sort()
|
||||
.map((manifestPath) => {
|
||||
const absoluteManifestPath = resolve(packagesRoot, manifestPath)
|
||||
const manifest = JSON.parse(readFileSync(absoluteManifestPath, 'utf8')) as PackageManifest
|
||||
return { path: dirname(manifestPath), directory: dirname(absoluteManifestPath), manifest }
|
||||
})
|
||||
}
|
||||
|
||||
function publintConcurrency(total: number): number {
|
||||
if (total === 0) return 0
|
||||
|
||||
const raw = process.env[CONCURRENCY_ENV]
|
||||
if (raw !== undefined) {
|
||||
if (raw !== undefined && raw !== '') {
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) {
|
||||
throw new Error(`publint-all: ${CONCURRENCY_ENV} must be a positive integer, got ${JSON.stringify(raw)}.`)
|
||||
}
|
||||
return Math.min(total, parsed)
|
||||
@@ -49,57 +56,106 @@ function publintConcurrency(total: number): number {
|
||||
return Math.min(total, availableParallelism())
|
||||
}
|
||||
|
||||
function outputText(value: unknown): string {
|
||||
if (typeof value === 'string') return value
|
||||
if (Buffer.isBuffer(value)) return value.toString()
|
||||
return ''
|
||||
function publicationFiles(target: PackageTarget): PackFile[] {
|
||||
const paths = new Set<string>()
|
||||
addPath(resolve(target.directory, 'package.json'), paths)
|
||||
const declared = Array.isArray(target.manifest.files)
|
||||
? target.manifest.files.filter((value): value is string => typeof value === 'string')
|
||||
: []
|
||||
for (const pattern of [
|
||||
...declared,
|
||||
'README*',
|
||||
'LICENSE*',
|
||||
'LICENCE*',
|
||||
'CHANGELOG*',
|
||||
'CHANGES*',
|
||||
'HISTORY*',
|
||||
'NOTICE*',
|
||||
]) {
|
||||
for (const match of globSync(pattern, { cwd: target.directory })) {
|
||||
addPath(resolve(target.directory, match), paths)
|
||||
}
|
||||
}
|
||||
|
||||
return [...paths]
|
||||
.sort()
|
||||
.map(path => ({
|
||||
name: `package/${relative(target.directory, path).split(sep).join('/')}`,
|
||||
data: readFileSync(path),
|
||||
}))
|
||||
}
|
||||
|
||||
async function runPublint(path: string): Promise<PublintResult> {
|
||||
function addPath(path: string, paths: Set<string>): void {
|
||||
const stat = statSync(path)
|
||||
if (stat.isDirectory()) {
|
||||
for (const entry of readdirSync(path)) addPath(resolve(path, entry), paths)
|
||||
} else if (stat.isFile()) {
|
||||
paths.add(path)
|
||||
}
|
||||
}
|
||||
|
||||
async function runPublint(target: PackageTarget): Promise<PublintResult> {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(process.execPath, [publintCli, path], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
const result = await publint({
|
||||
pkgDir: 'package',
|
||||
pack: { files: publicationFiles(target) },
|
||||
})
|
||||
return { path, status: 'passed', stdout, stderr }
|
||||
const manifest = result.pkg as Record<string, unknown>
|
||||
return result.messages.some(message => message.type === 'error')
|
||||
? { path: target.path, status: 'failed', messages: result.messages, manifest }
|
||||
: { path: target.path, status: 'passed', messages: result.messages, manifest }
|
||||
} catch (error: unknown) {
|
||||
const failed = error as { stdout?: unknown; stderr?: unknown; message?: string }
|
||||
return {
|
||||
path,
|
||||
path: target.path,
|
||||
status: 'failed',
|
||||
stdout: outputText(failed.stdout),
|
||||
stderr: outputText(failed.stderr),
|
||||
message: failed.message ?? 'publint failed',
|
||||
messages: [],
|
||||
manifest: target.manifest as Record<string, unknown>,
|
||||
failure: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runAll(paths: string[], concurrency: number): Promise<PublintResult[]> {
|
||||
async function runAll(targets: PackageTarget[], concurrency: number): Promise<PublintResult[]> {
|
||||
let next = 0
|
||||
const results: Array<PublintResult | undefined> = []
|
||||
await Promise.all(Array.from({ length: concurrency }, async () => {
|
||||
for (;;) {
|
||||
const index = next
|
||||
next += 1
|
||||
const path = paths[index]
|
||||
if (path === undefined) return
|
||||
results[index] = await runPublint(path)
|
||||
const target = targets[index]
|
||||
if (target === undefined) return
|
||||
results[index] = await runPublint(target)
|
||||
}
|
||||
}))
|
||||
|
||||
return paths.map((path, index) => {
|
||||
return targets.map((target, index) => {
|
||||
const result = results[index]
|
||||
if (result === undefined) throw new Error(`publint-all: missing result for ${path}.`)
|
||||
if (result === undefined) throw new Error(`publint-all: missing result for ${target.path}.`)
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
function printResult(result: PublintResult): void {
|
||||
console.log(`Running publint for ${result.path}...`)
|
||||
process.stdout.write(result.stdout)
|
||||
process.stderr.write(result.stderr)
|
||||
if (result.status === 'failed') console.error(result.message)
|
||||
if ('failure' in result) console.error(result.failure)
|
||||
for (const message of result.messages) {
|
||||
console.log(formatMessage(message, result.manifest, { color: false }) ?? message.code)
|
||||
}
|
||||
if (result.status === 'passed' && result.messages.length === 0) console.log('All good!')
|
||||
}
|
||||
|
||||
function parseOptions(args: string[]): Map<string, string> {
|
||||
const parsed = new Map<string, string>()
|
||||
for (let index = 0; index < args.length; index += 2) {
|
||||
const name = args[index]
|
||||
const value = args[index + 1]
|
||||
if (name !== '--packages-root' || value === undefined || value.startsWith('--')) {
|
||||
throw new Error(`publint-all: expected [--packages-root PATH], got ${JSON.stringify(args)}.`)
|
||||
}
|
||||
if (parsed.has(name)) throw new Error(`publint-all: duplicate option ${name}.`)
|
||||
parsed.set(name, value)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
const packages = workspacePackages()
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
/**
|
||||
* Shared source of truth for the RFC index: the tree walker (structure rules) and the README
|
||||
* table renderer. `gen-rfc-index.ts` writes the generated regions;
|
||||
* `verify-rfc-classification.ts` checks structure and asserts the committed regions are fresh.
|
||||
* Lifecycle and class sets are closed under `docs/rfc/README.md`; rows derive
|
||||
* from path, H1, and filename date and sort deterministically. Import is pure.
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import { globSync } from 'node:fs'
|
||||
|
||||
export const rfcRoot = resolve(import.meta.dirname, '../docs/rfc')
|
||||
|
||||
/** The closed set of RFC lifecycles (top-level folders under docs/rfc/). */
|
||||
const LIFECYCLES = ['proposed', 'implemented', 'rejected'] as const
|
||||
|
||||
/**
|
||||
* The closed set of RFC classes (nested folder under each lifecycle). Adding a
|
||||
* class is a deliberate act: extend this list AND the README's Classification
|
||||
* section. The gate rejects any folder not listed here.
|
||||
*/
|
||||
const CLASSES = ['feature', 'bug-fix', 'simplification', 'architecture', 'process', 'testing'] as const
|
||||
|
||||
/** Non-RFC Markdown allowed to sit directly at a lifecycle root. */
|
||||
const ROOT_ALLOWLIST = new Set(['AGENTS.md', 'CLAUDE.md'])
|
||||
|
||||
/** Title-case a class/lifecycle folder name for a README heading. */
|
||||
const heading = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1)
|
||||
|
||||
/** One RFC file, as discovered by the walker. */
|
||||
export interface Rfc {
|
||||
lifecycle: string
|
||||
cls: string
|
||||
base: string
|
||||
/** Path relative to docs/rfc — the README link target. */
|
||||
rel: string
|
||||
/** H1 text with any `RFC: ` prefix stripped — the README row title. */
|
||||
title: string
|
||||
/** `yyyy-mm-dd` from the filename — the "First proposed" column. */
|
||||
date: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the RFC tree, enforcing the structure rules. Returns every valid RFC
|
||||
* plus one error string per violation (unknown lifecycle or class folder, bad
|
||||
* depth, bad filename, missing/malformed H1). Callers treat a non-empty error
|
||||
* list as fatal — the index is only generated from a structurally valid tree.
|
||||
*/
|
||||
export function walkRfcTree(): { rfcs: Rfc[]; errors: string[] } {
|
||||
const rfcs: Rfc[] = []
|
||||
const errors: string[] = []
|
||||
// The lifecycle set is closed too: any directory under docs/rfc/ that is not
|
||||
// a known lifecycle would otherwise hold RFCs invisible to the walk below.
|
||||
for (const entry of readdirSync(rfcRoot, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && !(LIFECYCLES as readonly string[]).includes(entry.name)) {
|
||||
errors.push(`structure: ${entry.name}/ — unknown lifecycle folder (allowed: ${LIFECYCLES.join(', ')})`)
|
||||
}
|
||||
}
|
||||
for (const lifecycle of LIFECYCLES) {
|
||||
for (const match of globSync(`${lifecycle}/**/*.md`, { cwd: rfcRoot }).map(path => path.split(sep).join('/')).sort()) {
|
||||
const segs = match.split('/')
|
||||
// Allowlisted file directly at the lifecycle root (e.g. implemented/AGENTS.md).
|
||||
if (segs.length === 2 && ROOT_ALLOWLIST.has(segs[1] ?? '')) continue
|
||||
// A Chinese counterpart (foo.zh.md, docs/i18n/README.md) is the SAME RFC,
|
||||
// indexed via its English filename; the pairing gate owns its consistency.
|
||||
if (match.endsWith('.zh.md')) continue
|
||||
const cls = segs[1]
|
||||
const base = segs[2]
|
||||
if (segs.length !== 3 || cls === undefined || base === undefined) {
|
||||
errors.push(`structure: ${match} — expected {lifecycle}/{class}/file.md (got depth ${segs.length})`)
|
||||
continue
|
||||
}
|
||||
if (!(CLASSES as readonly string[]).includes(cls)) {
|
||||
errors.push(`structure: ${match} — unknown class folder "${cls}" (allowed: ${CLASSES.join(', ')})`)
|
||||
continue
|
||||
}
|
||||
if (!/^\d{4}-\d{2}-\d{2}-.+\.md$/.test(base)) {
|
||||
errors.push(`structure: ${match} — filename must be yyyy-mm-dd-topic.md`)
|
||||
continue
|
||||
}
|
||||
const firstLine = readFileSync(resolve(rfcRoot, match), 'utf8').split('\n', 1)[0] ?? ''
|
||||
const h1 = /^#\s+(?:RFC:\s+)?(.+?)\s*$/.exec(firstLine)
|
||||
if (!h1?.[1]) {
|
||||
errors.push(`title: ${match} — first line must be an H1 (\`# RFC: <title>\` or \`# <title>\`), got: ${JSON.stringify(firstLine)}`)
|
||||
continue
|
||||
}
|
||||
rfcs.push({ lifecycle, cls, base, rel: match, title: h1[1], date: base.slice(0, 10) })
|
||||
}
|
||||
}
|
||||
return { rfcs, errors }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one lifecycle's section body: a `### {Class}` heading plus a
|
||||
* `| Title | First proposed |` table for every non-empty class, in CLASSES
|
||||
* order, rows sorted by date then filename.
|
||||
*/
|
||||
function renderLifecycle(rfcs: Rfc[], lifecycle: string): string {
|
||||
const sections: string[] = []
|
||||
for (const cls of CLASSES) {
|
||||
const rows = rfcs
|
||||
.filter(r => r.lifecycle === lifecycle && r.cls === cls)
|
||||
.sort((a, b) => a.date.localeCompare(b.date) || a.base.localeCompare(b.base))
|
||||
if (rows.length === 0) continue
|
||||
const table = rows.map(r => `| [${r.title}](${r.rel}) | ${r.date} |`).join('\n')
|
||||
sections.push(`### ${heading(cls)}\n\n| Title | First proposed |\n|---|---|\n${table}`)
|
||||
}
|
||||
return sections.join('\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the complete `docs/rfc/INDEX.md` content: a generated-file banner
|
||||
* followed by one `## {Lifecycle}` section per lifecycle in canonical order.
|
||||
* The whole file is generated state — there is no curated region to preserve.
|
||||
*/
|
||||
export function renderIndex(rfcs: Rfc[]): string {
|
||||
const parts = [
|
||||
'# RFC index',
|
||||
'',
|
||||
'Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; `verify-rfc-classification` fails when this file is stale. The curated front door — layout, classification, when to write one, and the in-file format — is [README.md](README.md).',
|
||||
]
|
||||
for (const lifecycle of LIFECYCLES) {
|
||||
parts.push('', `## ${heading(lifecycle)}`, '', renderLifecycle(rfcs, lifecycle))
|
||||
}
|
||||
return `${parts.join('\n')}\n`
|
||||
}
|
||||
|
||||
/** Matches an index-shaped table row (a `| [title](lifecycle/…) |` line) — generated state that must not appear in curated prose. */
|
||||
export const INDEX_ROW = /^\|\s*\[[^\]]+\]\((?:proposed|implemented|rejected)\//
|
||||
@@ -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 =
|
||||
@@ -17,19 +16,26 @@ type Mode =
|
||||
| 'ci-coverage'
|
||||
| 'ci-snapshot'
|
||||
| 'ci-artifacts'
|
||||
| 'ci-windows-blocking'
|
||||
| 'ci-windows-complete'
|
||||
| 'ci-windows-observational'
|
||||
| 'node-compat'
|
||||
| 'pre-push'
|
||||
| 'check-all'
|
||||
| 'doc-sync'
|
||||
type GateStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped'
|
||||
|
||||
interface Gate {
|
||||
id: string
|
||||
label: string
|
||||
displayCommand: string
|
||||
command: string
|
||||
args: string[]
|
||||
needs?: string[]
|
||||
env?: Record<string, string | undefined>
|
||||
input?: string
|
||||
verify?: (result: GateResult) => Promise<void>
|
||||
allowFailure?: boolean
|
||||
}
|
||||
|
||||
interface GateResult {
|
||||
@@ -38,27 +44,46 @@ interface GateResult {
|
||||
durationMs: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
output: GateOutputChunk[]
|
||||
exitCode: number | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface GateOutputChunk {
|
||||
stream: 'stdout' | 'stderr'
|
||||
text: string
|
||||
}
|
||||
|
||||
interface RunningGate {
|
||||
gate: Gate
|
||||
promise: Promise<GateResult>
|
||||
}
|
||||
|
||||
interface ConcurrencyDefault {
|
||||
workers: number
|
||||
source: string
|
||||
}
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const mode = parseMode(process.argv[2])
|
||||
const gates = gatesForMode(mode)
|
||||
const maxConcurrency = concurrencyFromEnv('DSH_GATE_CONCURRENCY', defaultConcurrency(gates.length))
|
||||
const concurrencyDefault = defaultConcurrency(mode, gates.length)
|
||||
const concurrencyOverride = process.env.DSH_GATE_CONCURRENCY
|
||||
const maxConcurrency = concurrencyFromEnv('DSH_GATE_CONCURRENCY', concurrencyDefault.workers)
|
||||
const verbose = process.env.DSH_GATE_VERBOSE === '1'
|
||||
const startedAt = performance.now()
|
||||
|
||||
console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcurrency} worker(s).`)
|
||||
const concurrencySource = concurrencyOverride === undefined || concurrencyOverride === ''
|
||||
? concurrencyDefault.source
|
||||
: '$DSH_GATE_CONCURRENCY'
|
||||
console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcurrency} worker(s) from ${concurrencySource}.`)
|
||||
|
||||
const results = await runGates(gates, maxConcurrency)
|
||||
printSummary(results, performance.now() - startedAt)
|
||||
|
||||
if (results.some(result => result.status === 'failed' || result.status === 'skipped')) process.exit(1)
|
||||
if (results.some(result => result.gate.allowFailure !== true && (result.status === 'failed' || result.status === 'skipped'))) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function parseMode(raw: string | undefined): Mode {
|
||||
switch (raw) {
|
||||
@@ -68,18 +93,33 @@ function parseMode(raw: string | undefined): Mode {
|
||||
case 'ci-coverage':
|
||||
case 'ci-snapshot':
|
||||
case 'ci-artifacts':
|
||||
case 'ci-windows-blocking':
|
||||
case 'ci-windows-complete':
|
||||
case 'ci-windows-observational':
|
||||
case 'node-compat':
|
||||
case 'pre-push':
|
||||
case 'check-all':
|
||||
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 | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | pre-push | check-all | doc-sync, got ${JSON.stringify(raw)}.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function defaultConcurrency(total: number): number {
|
||||
return Math.min(total, Math.max(4, availableParallelism()))
|
||||
function defaultConcurrency(selectedMode: Mode, total: number): ConcurrencyDefault {
|
||||
const available = availableParallelism()
|
||||
// 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 === 'check-all' || selectedMode === 'doc-sync'
|
||||
const modeLimit = localCap ? Math.min(4, available) : available
|
||||
return {
|
||||
workers: Math.min(total, modeLimit),
|
||||
source: localCap
|
||||
? `${available} available CPU(s), ${selectedMode} cap 4`
|
||||
: `${available} available CPU(s)`,
|
||||
}
|
||||
}
|
||||
|
||||
function concurrencyFromEnv(name: string, fallback: number): number {
|
||||
@@ -96,6 +136,7 @@ function pnpmScript(id: string, script: string, options: Partial<Gate> = {}): Ga
|
||||
return {
|
||||
id,
|
||||
label: options.label ?? script,
|
||||
displayCommand: `pnpm run ${script}`,
|
||||
...pnpmInvocation(['run', script]),
|
||||
...options,
|
||||
}
|
||||
@@ -105,6 +146,7 @@ function pnpmExec(id: string, args: string[], options: Partial<Gate> = {}): Gate
|
||||
return {
|
||||
id,
|
||||
label: options.label ?? `pnpm exec ${args.join(' ')}`,
|
||||
displayCommand: `pnpm exec ${args.join(' ')}`,
|
||||
...pnpmInvocation(['exec', ...args]),
|
||||
...options,
|
||||
}
|
||||
@@ -135,36 +177,39 @@ function gatesForMode(selected: Mode): Gate[] {
|
||||
pnpmScript('duplication', 'duplication'),
|
||||
]
|
||||
case 'ci-coverage':
|
||||
return [
|
||||
coverageGate(),
|
||||
]
|
||||
return [coverageGate()]
|
||||
case 'ci-snapshot':
|
||||
return [
|
||||
pnpmScript('snapshot', 'test:snapshot'),
|
||||
]
|
||||
return [pnpmScript('build', 'build'), snapshotGate()]
|
||||
case 'ci-artifacts':
|
||||
return ciArtifactGates()
|
||||
case 'ci-windows-blocking':
|
||||
return ciWindowsBlockingGates()
|
||||
case 'ci-windows-complete':
|
||||
return ciWindowsCompleteGates()
|
||||
case 'ci-windows-observational':
|
||||
return ciWindowsObservationalGates()
|
||||
case 'node-compat':
|
||||
return [
|
||||
pnpmScript('typecheck', 'typecheck'),
|
||||
pnpmExec('source-worker-smoke', [
|
||||
'vitest',
|
||||
'run',
|
||||
'packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts',
|
||||
], { label: 'source worker smoke' }),
|
||||
]
|
||||
case 'pre-push':
|
||||
return nodeCompatGates()
|
||||
case 'pre-push': return []
|
||||
case 'check-all':
|
||||
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'),
|
||||
pnpmScript('snapshot', 'test:snapshot'),
|
||||
snapshotGate(),
|
||||
pnpmScript('build', 'build'),
|
||||
pnpmScript('build:web', 'build:web'),
|
||||
...hygieneLeafGates({ artifactNeeds: ['build'] }),
|
||||
...docSyncLeafGates(),
|
||||
...docSyncLeafGates({
|
||||
docTypecheckNeeds: ['build'],
|
||||
docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' },
|
||||
}),
|
||||
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
|
||||
]
|
||||
case 'doc-sync':
|
||||
return docSyncLeafGates()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,43 +217,67 @@ 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(),
|
||||
pnpmScript('snapshot', 'test:snapshot'),
|
||||
demoSmokeGate({ needs: ['lint'] }),
|
||||
...nodeCompatSmokeGates(),
|
||||
snapshotGate(),
|
||||
...docSyncLeafGates(),
|
||||
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
|
||||
pnpmScript('knip', 'knip'),
|
||||
pnpmScript('build', 'build', { needs: ['typecheck'] }),
|
||||
pnpmScript('build', 'build'),
|
||||
pnpmScript('publint', 'publint', { needs: ['build'] }),
|
||||
pnpmScript('node-next-types', 'verify-node-next-types', {
|
||||
label: 'node-next types',
|
||||
needs: ['build'],
|
||||
}),
|
||||
builtPackageInvariantsGate(['build']),
|
||||
builtBinSmokeGate(),
|
||||
]
|
||||
}
|
||||
|
||||
function nodeCompatGates(): Gate[] {
|
||||
return [
|
||||
...flagEnabled('DSH_NODE_COMPAT_SKIP_TYPECHECK') ? [] : [pnpmScript('typecheck', 'typecheck')],
|
||||
...nodeCompatSmokeGates(),
|
||||
]
|
||||
}
|
||||
|
||||
function nodeCompatSmokeGates(): Gate[] {
|
||||
return [
|
||||
pnpmExec('source-worker-smoke', [
|
||||
'vitest',
|
||||
'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' }),
|
||||
]
|
||||
}
|
||||
|
||||
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('build', 'build'),
|
||||
...docSyncLeafGates({
|
||||
docTypecheckNeeds: ['build'],
|
||||
docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' },
|
||||
docsBuildScript: 'docs:build:mpa',
|
||||
}),
|
||||
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
|
||||
pnpmScript('knip', 'knip'),
|
||||
]
|
||||
}
|
||||
|
||||
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'),
|
||||
@@ -217,15 +286,59 @@ function ciArtifactGates(): Gate[] {
|
||||
label: 'node-next types',
|
||||
needs: ['build'],
|
||||
}),
|
||||
builtPackageInvariantsGate(['build']),
|
||||
builtBinSmokeGate(),
|
||||
]
|
||||
}
|
||||
|
||||
function lintGate(): Gate {
|
||||
function ciWindowsBlockingGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('windows-build', 'build', { label: 'build' }),
|
||||
pnpmScript('windows-site', 'docs:build', { label: 'production site' }),
|
||||
]
|
||||
}
|
||||
|
||||
function ciWindowsCompleteGates(): Gate[] {
|
||||
const observational = ciWindowsObservationalGates()
|
||||
// The required production site replaces the observational MPA build; both
|
||||
// VitePress modes write the same output directory and cannot overlap.
|
||||
.filter(gate => gate.id !== 'build' && gate.id !== 'docs-site-build')
|
||||
.map(gate => ({ ...gate, allowFailure: true }))
|
||||
return [
|
||||
pnpmScript('build', 'build'),
|
||||
pnpmScript('windows-site', 'docs:build', { label: 'production site' }),
|
||||
...observational,
|
||||
]
|
||||
}
|
||||
|
||||
function ciWindowsObservationalGates(): Gate[] {
|
||||
return [
|
||||
...ciStaticGates(),
|
||||
lintGate(),
|
||||
pnpmScript('duplication', 'duplication'),
|
||||
{
|
||||
...coverageGate(),
|
||||
env: { DSH_EXAMPLE_MODE: 'lib' },
|
||||
needs: ['build'],
|
||||
},
|
||||
snapshotGate(),
|
||||
pnpmScript('publint', 'publint', { needs: ['build'] }),
|
||||
pnpmScript('node-next-types', 'verify-node-next-types', {
|
||||
label: 'node-next types',
|
||||
needs: ['build'],
|
||||
}),
|
||||
builtPackageInvariantsGate(['build']),
|
||||
builtBinSmokeGate(),
|
||||
]
|
||||
}
|
||||
|
||||
function lintGate(eslintTargets: readonly string[] = ['.']): Gate {
|
||||
const concurrencyArgs = eslintConcurrencyArgs()
|
||||
if (process.env.DSH_ESLINT_CACHE === '1') {
|
||||
return pnpmExec('lint', [
|
||||
'eslint',
|
||||
'.',
|
||||
...eslintTargets,
|
||||
...concurrencyArgs,
|
||||
'--cache',
|
||||
'--cache-location',
|
||||
'.cache/eslint/',
|
||||
@@ -236,11 +349,28 @@ function lintGate(): Gate {
|
||||
env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') },
|
||||
})
|
||||
}
|
||||
if (concurrencyArgs.length > 0) {
|
||||
return pnpmExec('lint', ['eslint', ...eslintTargets, ...concurrencyArgs], {
|
||||
label: 'lint',
|
||||
env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') },
|
||||
})
|
||||
}
|
||||
return pnpmScript('lint', 'lint', {
|
||||
env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') },
|
||||
})
|
||||
}
|
||||
|
||||
function eslintConcurrencyArgs(): string[] {
|
||||
const raw = process.env.DSH_ESLINT_CONCURRENCY
|
||||
if (raw === undefined || raw === '') return []
|
||||
if (raw === 'auto') return ['--concurrency=auto']
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) {
|
||||
throw new Error(`run-gates: DSH_ESLINT_CONCURRENCY must be a positive integer or auto, got ${JSON.stringify(raw)}.`)
|
||||
}
|
||||
return [`--concurrency=${raw}`]
|
||||
}
|
||||
|
||||
function coverageGate(): Gate {
|
||||
return pnpmExec('coverage', [
|
||||
'vitest',
|
||||
@@ -252,6 +382,23 @@ function coverageGate(): Gate {
|
||||
})
|
||||
}
|
||||
|
||||
// The snapshot suite boots the example bins in `lib` mode (built artifact under plain Node,
|
||||
// plugins via real exports) — CI and check-all already build, so they exercise what ships rather
|
||||
// than the tsx/source path dev uses. It therefore waits on `build`.
|
||||
function snapshotGate(): Gate {
|
||||
return pnpmScript('snapshot', 'test:snapshot', {
|
||||
env: { DSH_EXAMPLE_MODE: 'lib' },
|
||||
needs: ['build'],
|
||||
})
|
||||
}
|
||||
|
||||
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 []
|
||||
@@ -262,12 +409,21 @@ function positiveIntArg(envName: string, flag: string): string[] {
|
||||
return [`${flag}=${raw}`]
|
||||
}
|
||||
|
||||
function flagEnabled(envName: string): boolean {
|
||||
const raw = process.env[envName]
|
||||
if (raw === undefined || raw === '') return false
|
||||
if (raw !== '1') throw new Error(`run-gates: ${envName} must be 1 when set, got ${JSON.stringify(raw)}.`)
|
||||
return true
|
||||
}
|
||||
|
||||
function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
|
||||
const artifactOptions = options.artifactNeeds === undefined ? {} : { needs: options.artifactNeeds }
|
||||
return [
|
||||
pnpmScript('knip', 'knip'),
|
||||
pnpmScript('publint', 'publint', artifactOptions),
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
|
||||
builtPackageInvariantsGate(options.artifactNeeds),
|
||||
pnpmScript('node-next-types', 'verify-node-next-types', {
|
||||
label: 'node-next types',
|
||||
...artifactOptions,
|
||||
@@ -275,10 +431,18 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
|
||||
]
|
||||
}
|
||||
|
||||
function docSyncLeafGates(): Gate[] {
|
||||
function docSyncLeafGates(options: {
|
||||
docTypecheckNeeds?: string[]
|
||||
docTypecheckEnv?: Record<string, string | undefined>
|
||||
docsBuildScript?: 'docs:build' | 'docs:build:mpa'
|
||||
} = {}): Gate[] {
|
||||
const docTypecheckOptions: Partial<Gate> = {}
|
||||
if (options.docTypecheckNeeds !== undefined) docTypecheckOptions.needs = options.docTypecheckNeeds
|
||||
if (options.docTypecheckEnv !== undefined) docTypecheckOptions.env = options.docTypecheckEnv
|
||||
return [
|
||||
pnpmScript('doc-typecheck', 'doc-typecheck'),
|
||||
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' }),
|
||||
@@ -291,60 +455,32 @@ function docSyncLeafGates(): Gate[] {
|
||||
pnpmScript('package-paths', 'verify-package-paths', { label: 'package paths' }),
|
||||
pnpmScript('package-readme-model-experience', 'verify-package-readme-model-experience', { label: 'package README model experience' }),
|
||||
pnpmScript('mermaid', 'verify-mermaid'),
|
||||
pnpmScript('rfc-classification', 'verify-rfc-classification', { label: 'rfc classification' }),
|
||||
pnpmScript('rfc-format', 'verify-rfc-format', { label: 'rfc format' }),
|
||||
pnpmScript('agent-note-classification', 'verify-agent-note-classification', { label: 'agent note classification' }),
|
||||
pnpmScript('agent-note-format', 'verify-agent-note-format', { label: 'agent note format' }),
|
||||
pnpmScript('type-equivalence', 'verify-type-equiv', { label: 'type equivalence' }),
|
||||
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' }),
|
||||
pnpmExec('docs-site-projection', ['vitest', 'run', 'scripts/project-doc-site.spec.ts'], {
|
||||
label: 'documentation projection',
|
||||
}),
|
||||
// Keep the VitePress build itself in one gate because projection rewrites website/.generated.
|
||||
pnpmScript('docs-site-build', options.docsBuildScript ?? 'docs:build', { label: 'documentation build' }),
|
||||
pnpmScript('package-readme-limitations', 'verify-package-readme-limitations', { label: 'package README limitations' }),
|
||||
]
|
||||
}
|
||||
|
||||
function demoSmokeGate(options: { needs?: string[] } = {}): Gate {
|
||||
const dependencyOptions = options.needs === undefined ? {} : { needs: options.needs }
|
||||
return {
|
||||
id: 'demo-smoke',
|
||||
label: 'demo smoke',
|
||||
...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',
|
||||
// The worker-entry packages' built bundles: the only automated proof
|
||||
// that lib/index.js resolves its sibling lib/worker.cjs under plain node
|
||||
// (the e2e lane runs unbuilt, so these files self-skip there).
|
||||
@@ -353,6 +489,7 @@ function builtBinSmokeGate(): Gate {
|
||||
], {
|
||||
label: 'built-bin smoke',
|
||||
needs: ['build'],
|
||||
env: { DSH_EXAMPLE_MODE: 'lib' },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -382,6 +519,7 @@ async function runGates(allGates: Gate[], maxActive: number): Promise<GateResult
|
||||
durationMs: 0,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
output: [],
|
||||
exitCode: null,
|
||||
error: `dependency failed or skipped: ${failedDeps.join(', ')}`,
|
||||
}
|
||||
@@ -416,8 +554,10 @@ async function runGate(gate: Gate): Promise<GateResult> {
|
||||
const started = performance.now()
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
const output: GateOutputChunk[] = []
|
||||
let spawnError: string | undefined
|
||||
|
||||
const exitCode = await new Promise<number | null>((resolveExit, reject) => {
|
||||
const exitCode = await new Promise<number | null>((resolveExit) => {
|
||||
const child = spawn(gate.command, gate.args, {
|
||||
cwd: root,
|
||||
env: { ...process.env, ...gate.env },
|
||||
@@ -425,19 +565,28 @@ async function runGate(gate: Gate): Promise<GateResult> {
|
||||
})
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stdout.on('data', (chunk: string) => { stdout += chunk })
|
||||
child.stderr.on('data', (chunk: string) => { stderr += chunk })
|
||||
child.on('error', reject)
|
||||
child.stdout.on('data', (chunk: string) => {
|
||||
stdout += chunk
|
||||
output.push({ stream: 'stdout', text: chunk })
|
||||
})
|
||||
child.stderr.on('data', (chunk: string) => {
|
||||
stderr += chunk
|
||||
output.push({ stream: 'stderr', text: chunk })
|
||||
})
|
||||
child.on('error', (error) => {
|
||||
spawnError = `failed to start command: ${error.message}`
|
||||
resolveExit(null)
|
||||
})
|
||||
child.on('close', resolveExit)
|
||||
if (gate.input !== undefined) child.stdin.end(gate.input)
|
||||
else child.stdin.end()
|
||||
})
|
||||
|
||||
let status: GateStatus = exitCode === 0 ? 'passed' : 'failed'
|
||||
let error: string | undefined
|
||||
let status: GateStatus = exitCode === 0 && spawnError === undefined ? 'passed' : 'failed'
|
||||
let error = spawnError
|
||||
if (status === 'passed' && gate.verify !== undefined) {
|
||||
try {
|
||||
await gate.verify({ gate, status, durationMs: performance.now() - started, stdout, stderr, exitCode })
|
||||
await gate.verify({ gate, status, durationMs: performance.now() - started, stdout, stderr, output, exitCode })
|
||||
} catch (verifyError: unknown) {
|
||||
status = 'failed'
|
||||
error = verifyError instanceof Error ? verifyError.message : String(verifyError)
|
||||
@@ -450,6 +599,7 @@ async function runGate(gate: Gate): Promise<GateResult> {
|
||||
durationMs: performance.now() - started,
|
||||
stdout,
|
||||
stderr,
|
||||
output,
|
||||
exitCode,
|
||||
}
|
||||
if (error !== undefined) result.error = error
|
||||
@@ -458,9 +608,16 @@ async function runGate(gate: Gate): Promise<GateResult> {
|
||||
|
||||
function printResult(result: GateResult): void {
|
||||
const seconds = (result.durationMs / 1000).toFixed(2)
|
||||
console.log(`\n== ${result.status.toUpperCase()} ${result.gate.label} (${seconds}s) ==`)
|
||||
process.stdout.write(result.stdout)
|
||||
process.stderr.write(result.stderr)
|
||||
if (result.status === 'passed' && !verbose) {
|
||||
console.log(`run-gates: PASS ${result.gate.label} (${seconds}s)`)
|
||||
return
|
||||
}
|
||||
|
||||
const heading = `${result.status.toUpperCase()} ${result.gate.label} (${seconds}s)`
|
||||
const writeHeading = result.status === 'passed' ? console.log : console.error
|
||||
writeHeading(`\n== ${heading} ==`)
|
||||
if (result.status !== 'passed') console.error(`command: ${result.gate.displayCommand}`)
|
||||
printOutput(result.output)
|
||||
if (result.error !== undefined) console.error(result.error)
|
||||
}
|
||||
|
||||
@@ -470,4 +627,23 @@ function printSummary(results: GateResult[], durationMs: number): void {
|
||||
const skipped = results.filter(result => result.status === 'skipped').length
|
||||
const seconds = (durationMs / 1000).toFixed(2)
|
||||
console.log(`\nrun-gates: ${passed} passed, ${failed} failed, ${skipped} skipped in ${seconds}s.`)
|
||||
|
||||
const unsuccessful = results.filter(result => result.status === 'failed' || result.status === 'skipped')
|
||||
if (unsuccessful.length === 0) return
|
||||
|
||||
console.error('run-gates: unsuccessful gates:')
|
||||
for (const result of unsuccessful) {
|
||||
const duration = (result.durationMs / 1000).toFixed(2)
|
||||
const reason = result.error ?? (result.exitCode === null ? 'no exit code' : `exit ${result.exitCode}`)
|
||||
const disposition = result.gate.allowFailure === true ? 'NON-BLOCKING ' : ''
|
||||
console.error(` - ${disposition}${result.status.toUpperCase()} ${result.gate.label} (${duration}s, ${reason})`)
|
||||
console.error(` ${result.gate.displayCommand}`)
|
||||
}
|
||||
}
|
||||
|
||||
function printOutput(output: GateOutputChunk[]): void {
|
||||
for (const chunk of output) {
|
||||
if (chunk.stream === 'stdout') process.stdout.write(chunk.text)
|
||||
else process.stderr.write(chunk.text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,12 +57,14 @@ CUSTOM_CORDIS = """\
|
||||
- id: agent-core
|
||||
name: '@deepseek-ai/dsh-agent-spine-demo'
|
||||
config:
|
||||
workspaceContext: false
|
||||
tools:
|
||||
mode: both
|
||||
- id: sessions
|
||||
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:
|
||||
@@ -379,6 +381,7 @@ def smoke_sdk_default(base_url: str) -> None:
|
||||
root = Path(temporary).resolve()
|
||||
sessions = root / "sessions"
|
||||
with DeepSeekHarness(
|
||||
provider="deepseek",
|
||||
model="smoke-model",
|
||||
cwd=str(root),
|
||||
session_root=str(sessions),
|
||||
@@ -389,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:
|
||||
@@ -401,6 +404,7 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None:
|
||||
cordis = root / "cordis.yml"
|
||||
cordis.write_text(CUSTOM_CORDIS)
|
||||
with DeepSeekHarness(
|
||||
provider="deepseek",
|
||||
model="smoke-model",
|
||||
cwd=str(root),
|
||||
session_root=str(sessions),
|
||||
@@ -432,6 +436,7 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool)
|
||||
cordis = root / "cordis.yml"
|
||||
cordis.write_text(CUSTOM_CORDIS)
|
||||
with DeepSeekHarness(
|
||||
provider="deepseek",
|
||||
model="smoke-model",
|
||||
cwd=str(root),
|
||||
session_root=str(sessions),
|
||||
@@ -481,7 +486,7 @@ def smoke_direct(base_url: str, executable: Path) -> None:
|
||||
}
|
||||
peer = RuntimePeer([str(executable)], root, environment)
|
||||
try:
|
||||
peer.send({"jsonrpc": "2.0", "id": "initialize", "method": "initialize", "params": {"cwd": str(root), "model": "smoke-model"}})
|
||||
peer.send({"jsonrpc": "2.0", "id": "initialize", "method": "initialize", "params": {"cwd": str(root), "provider": "deepseek", "model": "smoke-model"}})
|
||||
peer.read_until(lambda message: message.get("id") == "initialize")
|
||||
peer.send({
|
||||
"jsonrpc": "2.0",
|
||||
@@ -581,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]]] = {}
|
||||
@@ -624,7 +637,7 @@ def build_snapshot_files(
|
||||
child_ids: list[str],
|
||||
cwd: Path,
|
||||
) -> dict[str, str]:
|
||||
"""Render the SDK result and three persisted logs into stable goldens."""
|
||||
"""Render the SDK result and three persisted logs into stable expected outputs."""
|
||||
replacements = [(str(cwd), "{{cwd}}"), (SNAPSHOT_SESSION_ID, "{{parent}}")]
|
||||
for index, child_id in enumerate(child_ids, start=1):
|
||||
replacements.append((child_id, f"{{{{child-{index}}}}}"))
|
||||
@@ -703,7 +716,7 @@ def normalize_snapshot_value(
|
||||
|
||||
|
||||
def scrub_snapshot_header(value: dict[object, object]) -> None:
|
||||
"""Tokenize request-header bulk while retaining delta tool names."""
|
||||
"""Tokenize full request-header bulk while retaining tool names."""
|
||||
data = value.get("data")
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
@@ -721,29 +734,6 @@ def scrub_snapshot_header(value: dict[object, object]) -> None:
|
||||
]
|
||||
if isinstance(header.get("messagePrefix"), list):
|
||||
header["messagePrefix"] = ["{{messagePrefix}}" for _ in header["messagePrefix"]]
|
||||
return
|
||||
if value.get("type") != "request/header-delta":
|
||||
return
|
||||
system = data.get("system")
|
||||
if isinstance(system, dict) and isinstance(system.get("insert"), list):
|
||||
system["insert"] = ["{{system}}" for _ in system["insert"]]
|
||||
tools = data.get("tools")
|
||||
if isinstance(tools, dict):
|
||||
for key in ("added", "changed"):
|
||||
if isinstance(tools.get(key), list):
|
||||
tools[key] = [scrub_snapshot_tool_schema(tool) for tool in tools[key]]
|
||||
if isinstance(data.get("messagePrefix"), list):
|
||||
data["messagePrefix"] = ["{{messagePrefix}}" for _ in data["messagePrefix"]]
|
||||
|
||||
|
||||
def scrub_snapshot_tool_schema(value: object) -> object:
|
||||
"""Keep a changed tool's name while tokenizing its schema bulk."""
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
return {
|
||||
key: item if key == "name" else "{{tools}}"
|
||||
for key, item in value.items()
|
||||
}
|
||||
|
||||
|
||||
def render_jsonl(records: list[object]) -> str:
|
||||
|
||||
@@ -253,7 +253,7 @@
|
||||
"workflow"
|
||||
]
|
||||
},
|
||||
"reason": "fallback"
|
||||
"reason": "change"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -915,22 +915,29 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "request/header-delta",
|
||||
"type": "request/header",
|
||||
"seq": 56,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"system": {
|
||||
"keepStart": 62,
|
||||
"keepEnd": 34,
|
||||
"insert": []
|
||||
"header": {
|
||||
"config": {
|
||||
"model": "smoke-model"
|
||||
},
|
||||
"system": "{{system}}",
|
||||
"tools": [
|
||||
"bash",
|
||||
"bash_kill",
|
||||
"bash_output",
|
||||
"cordis_inspect",
|
||||
"cordis_mount",
|
||||
"cordis_unmount",
|
||||
"run_code",
|
||||
"skill",
|
||||
"subagent",
|
||||
"workflow"
|
||||
]
|
||||
},
|
||||
"tools": {
|
||||
"added": [],
|
||||
"removed": [
|
||||
"snapshot_double"
|
||||
],
|
||||
"changed": []
|
||||
}
|
||||
"reason": "change"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1396,7 +1403,7 @@
|
||||
"workflow"
|
||||
]
|
||||
},
|
||||
"reason": "fallback"
|
||||
"reason": "change"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2358,22 +2365,29 @@
|
||||
"payload": {
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "request/header-delta",
|
||||
"type": "request/header",
|
||||
"seq": 56,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"system": {
|
||||
"keepStart": 62,
|
||||
"keepEnd": 34,
|
||||
"insert": []
|
||||
"header": {
|
||||
"config": {
|
||||
"model": "smoke-model"
|
||||
},
|
||||
"system": "{{system}}",
|
||||
"tools": [
|
||||
"bash",
|
||||
"bash_kill",
|
||||
"bash_output",
|
||||
"cordis_inspect",
|
||||
"cordis_mount",
|
||||
"cordis_unmount",
|
||||
"run_code",
|
||||
"skill",
|
||||
"subagent",
|
||||
"workflow"
|
||||
]
|
||||
},
|
||||
"tools": {
|
||||
"added": [],
|
||||
"removed": [
|
||||
"snapshot_double"
|
||||
],
|
||||
"changed": []
|
||||
}
|
||||
"reason": "change"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"<anonymous>\", state: active)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}}
|
||||
{"type":"request/header","seq":14,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","workflow"]},"reason":"fallback"}}
|
||||
{"type":"request/header","seq":14,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","workflow"]},"reason":"change"}}
|
||||
{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}}}
|
||||
{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}}}}
|
||||
@@ -55,7 +55,7 @@
|
||||
{"type":"tool/result","seq":53,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"<anonymous>\")"}],"isError":false},"sourceEventSeqs":[52],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":54,"time":0,"data":{"turn":1,"step":5}}
|
||||
{"type":"step/start","seq":55,"time":0,"data":{"turn":1,"step":6}}
|
||||
{"type":"request/header-delta","seq":56,"time":0,"data":{"system":{"keepStart":62,"keepEnd":34,"insert":[]},"tools":{"added":[],"removed":["snapshot_double"],"changed":[]}}}
|
||||
{"type":"request/header","seq":56,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","subagent","workflow"]},"reason":"change"}}
|
||||
{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}}
|
||||
{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}}
|
||||
|
||||
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,14 @@
|
||||
{
|
||||
"requiredSince": "2026-07-14",
|
||||
"required": [
|
||||
".agents/notes/README.md",
|
||||
".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/architecture.md",
|
||||
"docs/cookbook/adding-a-package.md",
|
||||
@@ -37,15 +45,26 @@
|
||||
"docs/postmortem/0001-acp-default-export-drops-inject.md",
|
||||
"docs/postmortem/0002-js-expression-disabled-filesystem-tools.md",
|
||||
"docs/postmortem/README.md",
|
||||
"docs/rfc/README.md",
|
||||
"docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md",
|
||||
"docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md",
|
||||
"docs/testing.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"
|
||||
],
|
||||
"excluded": [
|
||||
".agents/notes/AGENTS.md",
|
||||
".agents/notes/implemented/AGENTS.md",
|
||||
"docs/AGENTS.md",
|
||||
"docs/agent-lifecycle.md",
|
||||
"docs/capability-seams.md",
|
||||
@@ -58,7 +77,6 @@
|
||||
"docs/i18n/translation-prompt.md",
|
||||
"docs/module-graph.md",
|
||||
"docs/persistence-catalog.md",
|
||||
"docs/rfc/INDEX.md",
|
||||
"docs/tool-catalog.md",
|
||||
"docs/tool-execution-pipeline.md",
|
||||
"python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/"
|
||||
|
||||
@@ -50,13 +50,13 @@ describe('date-based pairing frontier', () => {
|
||||
const cutoff = '2026-07-14'
|
||||
|
||||
it('enforces the cutoff day and every later day, but not the preceding day', () => {
|
||||
expect(requiresPairByDate('docs/rfc/2026-07-13-before.md', cutoff)).toBe(false)
|
||||
expect(requiresPairByDate('docs/rfc/2026-07-14-at-cutoff.md', cutoff)).toBe(true)
|
||||
expect(requiresPairByDate('docs/rfc/2026-07-15-after.md', cutoff)).toBe(true)
|
||||
expect(requiresPairByDate('.agents/notes/2026-07-13-before.md', cutoff)).toBe(false)
|
||||
expect(requiresPairByDate('.agents/notes/2026-07-14-at-cutoff.md', cutoff)).toBe(true)
|
||||
expect(requiresPairByDate('.agents/notes/2026-07-15-after.md', cutoff)).toBe(true)
|
||||
})
|
||||
|
||||
it('matches only a date at the start of the basename', () => {
|
||||
expect(datedDocumentDate('docs/rfc/2026-07-14-proposal.md')).toBe('2026-07-14')
|
||||
expect(datedDocumentDate('.agents/notes/2026-07-14-proposal.md')).toBe('2026-07-14')
|
||||
expect(datedDocumentDate('docs/release-notes-2026-07-14-alpha.md')).toBeUndefined()
|
||||
expect(requiresPairByDate('docs/release-notes-2026-07-14-alpha.md', cutoff)).toBe(false)
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
27
scripts/verify-agent-note-classification.ts
Normal file
27
scripts/verify-agent-note-classification.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Enforce Agent Note lifecycle/class paths and dated filenames. Structural rules
|
||||
* are shared with `agent-note-tree.ts`; the closed classification contract lives
|
||||
* in `.agents/notes/README.md`.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { walkAgentNoteTree } from './agent-note-tree.ts'
|
||||
|
||||
const { notes, errors } = walkAgentNoteTree()
|
||||
|
||||
// Keep the former homes unavailable so new notes cannot silently escape this tree.
|
||||
for (const legacyRoot of ['docs/rfc', 'docs/rfcs']) {
|
||||
if (existsSync(resolve(import.meta.dirname, '..', legacyRoot))) {
|
||||
errors.push(`legacy-path: ${legacyRoot}/ is forbidden — put Agent Notes under .agents/notes/`)
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length === 0) {
|
||||
console.log(`verify-agent-note-classification: ${notes.length} Agent Note(s) checked, structure consistent.`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.error('verify-agent-note-classification: violations found:')
|
||||
for (const e of errors) console.error(` ${e}`)
|
||||
process.exit(1)
|
||||
@@ -1,22 +1,22 @@
|
||||
/**
|
||||
* Enforce RFC headers, lifecycle-specific sections, alternatives, and retired
|
||||
* Enforce Agent Note headers, lifecycle-specific sections, alternatives, and retired
|
||||
* marker rules. Classification and filenames belong to the sibling tree gate;
|
||||
* translation structure belongs to the pairing gate. Exact format and
|
||||
* grandfathering rules live in `docs/rfc/README.md`.
|
||||
* grandfathering rules live in `.agents/notes/README.md`.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { rfcRoot, walkRfcTree } from './rfc-index.ts'
|
||||
import { agentNoteRoot, walkAgentNoteTree } from './agent-note-tree.ts'
|
||||
|
||||
/** The date the format contract landed; the grandfather comment is valid only before it. */
|
||||
const FORMAT_ADOPTED = '2026-07-05'
|
||||
|
||||
/** The exact comment a pre-format RFC carries in place of `## Alternatives considered`. */
|
||||
const GRANDFATHER = '<!-- rfc-format: alternatives-not-recorded (pre-format RFC) -->'
|
||||
/** The exact comment a pre-format Agent Note carries in place of `## Alternatives considered`. */
|
||||
const GRANDFATHER = '<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) -->'
|
||||
|
||||
/** The retired debt marker that flagged pre-format bodies; banned so it cannot creep back. */
|
||||
const LEGACY_MARKER = 'XXX: legacy ADR/RFC body format'
|
||||
const LEGACY_MARKERS = ['XXX: legacy ADR/RFC body format', 'XXX: legacy ADR/Agent Note body format']
|
||||
|
||||
/** Status-line grammar per lifecycle folder. */
|
||||
const STATUS: Record<string, RegExp> = {
|
||||
@@ -35,13 +35,13 @@ const REQUIRED: Record<string, string[]> = {
|
||||
/** Headings banned in `implemented/` — proposal-era spec-speak per the slop checklist. */
|
||||
const BANNED_IMPLEMENTED = /^## (?:Proposal\b|Plan\b|Migration plan\b|Acceptance criteria\b)/i
|
||||
|
||||
const { rfcs, errors } = walkRfcTree()
|
||||
const { notes, errors } = walkAgentNoteTree()
|
||||
|
||||
for (const rfc of rfcs) {
|
||||
for (const note of notes) {
|
||||
const fail = (msg: string): void => {
|
||||
errors.push(`format: ${rfc.rel} — ${msg}`)
|
||||
errors.push(`format: ${note.rel} — ${msg}`)
|
||||
}
|
||||
const lines = readFileSync(resolve(rfcRoot, rfc.rel), 'utf8').split('\n')
|
||||
const lines = readFileSync(resolve(agentNoteRoot, note.rel), 'utf8').split('\n')
|
||||
// Format tokens inside fenced examples are not document structure.
|
||||
let inFence = false
|
||||
const prose = lines.filter((l) => {
|
||||
@@ -52,11 +52,11 @@ for (const rfc of rfcs) {
|
||||
return !inFence
|
||||
})
|
||||
|
||||
if (!/^# RFC: \S/.test(lines[0] ?? '')) fail('line 1 must be `# RFC: <title>`')
|
||||
if (!/^# Agent Note: \S/.test(lines[0] ?? '')) fail('line 1 must be `# Agent Note: <title>`')
|
||||
if (lines[1] !== '') fail('line 2 must be blank')
|
||||
const status = STATUS[rfc.lifecycle]
|
||||
const status = STATUS[note.lifecycle]
|
||||
if (status !== undefined && !status.test(lines[2] ?? '')) {
|
||||
fail(`line 3 must match the ${rfc.lifecycle} status grammar (${String(status)})`)
|
||||
fail(`line 3 must match the ${note.lifecycle} status grammar (${String(status)})`)
|
||||
}
|
||||
if (lines[3] !== '') fail('line 4 must be blank')
|
||||
const statusLines = prose.filter(l => l.startsWith('Status:') && l !== lines[2])
|
||||
@@ -66,29 +66,29 @@ for (const rfc of rfcs) {
|
||||
|
||||
const h2s = prose.filter(l => l.startsWith('## ')).map(l => l.trimEnd())
|
||||
if (h2s[0] !== '## Problem') fail(`the first section must be \`## Problem\` (got ${JSON.stringify(h2s[0] ?? '<none>')})`)
|
||||
for (const required of REQUIRED[rfc.lifecycle] ?? []) {
|
||||
for (const required of REQUIRED[note.lifecycle] ?? []) {
|
||||
if (!h2s.includes(required)) fail(`missing the required \`${required}\` section`)
|
||||
}
|
||||
if (rfc.lifecycle === 'implemented') {
|
||||
if (note.lifecycle === 'implemented') {
|
||||
for (const h2 of h2s.filter(h => BANNED_IMPLEMENTED.test(h))) {
|
||||
fail(`\`${h2}\` is a proposal-era heading; an implemented RFC states what is (fold it into Decision/Consequences/Testing)`)
|
||||
fail(`\`${h2}\` is a proposal-era heading; an implemented Agent Note states what is (fold it into Decision/Consequences/Testing)`)
|
||||
}
|
||||
}
|
||||
|
||||
const hasSection = h2s.includes('## Alternatives considered')
|
||||
const hasGrandfather = prose.includes(GRANDFATHER)
|
||||
if (hasSection && hasGrandfather) fail('carries both `## Alternatives considered` and the grandfather comment — drop the comment')
|
||||
if (!hasSection && !hasGrandfather) fail('missing `## Alternatives considered` (a pre-format RFC whose alternatives are not reconstructible carries the grandfather comment instead — see docs/rfc/README.md § The file format)')
|
||||
if (hasGrandfather && rfc.date >= FORMAT_ADOPTED) fail(`the grandfather comment is only valid for RFCs dated before ${FORMAT_ADOPTED}`)
|
||||
if (!hasSection && !hasGrandfather) fail('missing `## Alternatives considered` (a pre-format Agent Note whose alternatives are not reconstructible carries the grandfather comment instead — see .agents/notes/README.md § The file format)')
|
||||
if (hasGrandfather && note.date >= FORMAT_ADOPTED) fail(`the grandfather comment is only valid for Agent Notes dated before ${FORMAT_ADOPTED}`)
|
||||
|
||||
if (prose.some(l => l.includes(LEGACY_MARKER))) fail('carries the retired legacy-format debt marker')
|
||||
if (prose.some(line => LEGACY_MARKERS.some(marker => line.includes(marker)))) fail('carries the retired legacy-format debt marker')
|
||||
}
|
||||
|
||||
if (errors.length === 0) {
|
||||
console.log(`verify-rfc-format: ${rfcs.length} RFC(s) checked, all conform to docs/rfc/README.md § The file format.`)
|
||||
console.log(`verify-agent-note-format: ${notes.length} Agent Note(s) checked, all conform to .agents/notes/README.md § The file format.`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.error('verify-rfc-format: violations found:')
|
||||
console.error('verify-agent-note-format: violations found:')
|
||||
for (const e of errors) console.error(` ${e}`)
|
||||
process.exit(1)
|
||||
106
scripts/verify-built-package-invariants.mjs
Normal file
106
scripts/verify-built-package-invariants.mjs
Normal file
@@ -0,0 +1,106 @@
|
||||
/** Verify every compiled companion through its staged package self-reference under plain Node. */
|
||||
|
||||
import {
|
||||
copyFileSync,
|
||||
cpSync,
|
||||
existsSync,
|
||||
globSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const repositoryRoot = resolve(import.meta.dirname, '..')
|
||||
const options = parseOptions(process.argv.slice(2))
|
||||
const packagesRoot = resolve(options.get('--packages-root') ?? repositoryRoot)
|
||||
const loaderUrl = options.get('--loader-url')
|
||||
?? pathToFileURL(resolve(repositoryRoot, 'vendor/loader/lib/index.js')).href
|
||||
const failures = []
|
||||
const manifests = globSync('packages/*/*/package.json', { cwd: packagesRoot }).sort()
|
||||
const { default: Loader } = await import(loaderUrl)
|
||||
const loader = Object.create(Loader.prototype)
|
||||
|
||||
for (const manifestPath of manifests) {
|
||||
const packageDir = dirname(resolve(packagesRoot, manifestPath))
|
||||
const manifest = JSON.parse(readFileSync(resolve(packagesRoot, manifestPath), 'utf8'))
|
||||
const packageName = manifest.name
|
||||
if (typeof packageName !== 'string' || packageName.length === 0) {
|
||||
failures.push(`${manifestPath}: missing package name`)
|
||||
continue
|
||||
}
|
||||
const invariantExport = manifest.exports?.['./invariant']
|
||||
if (typeof invariantExport !== 'object'
|
||||
|| invariantExport.default !== './lib/invariant.js'
|
||||
|| !manifest.files?.includes('lib/invariant.js')) {
|
||||
failures.push(`${packageName}: manifest does not publish ./lib/invariant.js as ./invariant`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Keep the staged 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. Copy the manifest-declared lib view
|
||||
// so a companion that imports an undeclared runtime chunk fails here.
|
||||
const stagedPackageDir = mkdtempSync(resolve(packageDir, '.dsh-built-invariant-'))
|
||||
try {
|
||||
copyFileSync(resolve(packageDir, 'package.json'), resolve(stagedPackageDir, 'package.json'))
|
||||
copyDeclaredLibFiles(packageDir, stagedPackageDir, manifest.files)
|
||||
const probePath = resolve(stagedPackageDir, 'probe.mjs')
|
||||
writeFileSync(
|
||||
probePath,
|
||||
`import * as companion from ${JSON.stringify(`${packageName}/invariant`)}\nexport default companion\n`,
|
||||
)
|
||||
const { default: companion } = await import(pathToFileURL(probePath).href)
|
||||
if ('default' in companion) throw new Error('companion has a default export')
|
||||
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')
|
||||
} catch (error) {
|
||||
failures.push(`${packageName}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
} finally {
|
||||
rmSync(stagedPackageDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('verify-built-package-invariants: compiled companion failures:')
|
||||
for (const failure of failures) console.error(` ${failure}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`verify-built-package-invariants: ${manifests.length} compiled companion(s) passed plain-Node Loader checks.`)
|
||||
|
||||
function parseOptions(args) {
|
||||
const allowed = new Set(['--packages-root', '--loader-url'])
|
||||
const parsed = new Map()
|
||||
for (let index = 0; index < args.length; index += 2) {
|
||||
const name = args[index]
|
||||
const value = args[index + 1]
|
||||
if (!allowed.has(name) || value === undefined || value.startsWith('--')) {
|
||||
throw new Error(`verify-built-package-invariants: expected [--packages-root PATH] [--loader-url URL], got ${JSON.stringify(args)}.`)
|
||||
}
|
||||
if (parsed.has(name)) throw new Error(`verify-built-package-invariants: duplicate option ${name}.`)
|
||||
parsed.set(name, value)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function copyDeclaredLibFiles(packageDir, stagedPackageDir, files) {
|
||||
for (const pattern of files) {
|
||||
if (!pattern.startsWith('lib/')) continue
|
||||
for (const relativePath of globSync(pattern, { cwd: packageDir })) {
|
||||
const source = resolve(packageDir, relativePath)
|
||||
if (!existsSync(source)) continue
|
||||
const target = resolve(stagedPackageDir, relativePath)
|
||||
mkdirSync(dirname(target), { recursive: true })
|
||||
cpSync(source, target, { recursive: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
88
scripts/verify-built-package-invariants.spec.ts
Normal file
88
scripts/verify-built-package-invariants.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const verifier = fileURLToPath(new URL('./verify-built-package-invariants.mjs', import.meta.url))
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function fixture(options: {
|
||||
invariantSource?: string
|
||||
invariantExport?: string
|
||||
runtimeChunk?: string
|
||||
} = {}): { root: string; loaderUrl: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-built-package-invariants-'))
|
||||
roots.push(root)
|
||||
const packageDir = join(root, 'packages/core/probe')
|
||||
mkdirSync(join(packageDir, 'lib'), { recursive: true })
|
||||
writeFileSync(join(packageDir, 'package.json'), `${JSON.stringify({
|
||||
name: '@deepseek-ai/dsh-probe',
|
||||
type: 'module',
|
||||
files: ['lib/invariant.js'],
|
||||
exports: {
|
||||
'./invariant': {
|
||||
default: options.invariantExport ?? './lib/invariant.js',
|
||||
},
|
||||
},
|
||||
}, null, 2)}\n`)
|
||||
writeFileSync(
|
||||
join(packageDir, 'lib/invariant.js'),
|
||||
options.invariantSource ?? "export const name = 'probe-invariant'\nexport const inject = ['invariants']\nexport const apply = () => {}\n",
|
||||
)
|
||||
if (options.runtimeChunk !== undefined) {
|
||||
writeFileSync(join(packageDir, 'lib/chunk.js'), options.runtimeChunk)
|
||||
}
|
||||
const loaderPath = join(root, 'loader.mjs')
|
||||
writeFileSync(loaderPath, 'export default class Loader { unwrapExports(value) { return value } }\n')
|
||||
return { root, loaderUrl: pathToFileURL(loaderPath).href }
|
||||
}
|
||||
|
||||
function verify(root: string, loaderUrl: string) {
|
||||
return spawnSync(process.execPath, [
|
||||
verifier,
|
||||
'--packages-root', root,
|
||||
'--loader-url', loaderUrl,
|
||||
], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5_000,
|
||||
})
|
||||
}
|
||||
|
||||
describe('built package invariant verifier', () => {
|
||||
it('loads the staged compiled self-reference through plain Node and Loader normalization', () => {
|
||||
const { root, loaderUrl } = fixture()
|
||||
const result = verify(root, loaderUrl)
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stdout).toContain('1 compiled companion(s) passed plain-Node Loader checks')
|
||||
})
|
||||
|
||||
it('rejects a default export and a broken invariant export map', () => {
|
||||
const withDefault = fixture({
|
||||
invariantSource: "export default {}\nexport const name = 'probe-invariant'\nexport const inject = ['invariants']\nexport const apply = () => {}\n",
|
||||
})
|
||||
const defaultResult = verify(withDefault.root, withDefault.loaderUrl)
|
||||
expect(defaultResult.status).toBe(1)
|
||||
expect(defaultResult.stderr).toContain('companion has a default export')
|
||||
|
||||
const brokenExport = fixture({ invariantExport: './lib/missing.js' })
|
||||
const exportResult = verify(brokenExport.root, brokenExport.loaderUrl)
|
||||
expect(exportResult.status).toBe(1)
|
||||
expect(exportResult.stderr).toContain('@deepseek-ai/dsh-probe')
|
||||
})
|
||||
|
||||
it('rejects an invariant bundle that needs an unstaged runtime chunk', () => {
|
||||
const { root, loaderUrl } = fixture({
|
||||
invariantSource: "export * from './chunk.js'\n",
|
||||
runtimeChunk: "export const name = 'probe-invariant'\nexport const inject = ['invariants']\nexport const apply = () => {}\n",
|
||||
})
|
||||
const result = verify(root, loaderUrl)
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('chunk.js')
|
||||
})
|
||||
})
|
||||
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.')
|
||||
@@ -1,18 +1,32 @@
|
||||
/**
|
||||
* Reject JavaScript expressions in Cordis Loader entry metadata.
|
||||
* Validate Cordis Loader entry metadata and example package resolution.
|
||||
*
|
||||
* The Loader interpolates only a plugin entry's `config`; expression objects in
|
||||
* fields such as `disabled` remain truthy data and silently change composition.
|
||||
* Example configs run from built packages, so every named package must resolve
|
||||
* from the examples workspace and every local package must be in the root
|
||||
* TypeScript project graph.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { dirname, relative, resolve } from 'node:path'
|
||||
import * as yaml from 'js-yaml'
|
||||
import ts from 'typescript'
|
||||
|
||||
interface JsExpr {
|
||||
__jsExpr: string
|
||||
}
|
||||
|
||||
interface PackageManifest {
|
||||
name?: string
|
||||
dependencies?: Record<string, string>
|
||||
}
|
||||
|
||||
interface PluginReference {
|
||||
file: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const metadataFields = ['id', 'name', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const
|
||||
const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
|
||||
@@ -30,6 +44,7 @@ const files = globSync(['**/*cordis*.yml', '**/*cordis*.yaml'], {
|
||||
exclude: ['.claude/**', 'node_modules/**', 'vendor/**'],
|
||||
}).sort()
|
||||
const errors: string[] = []
|
||||
const examplePluginReferences: PluginReference[] = []
|
||||
|
||||
for (const file of files) {
|
||||
const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema })
|
||||
@@ -42,8 +57,10 @@ for (const file of files) {
|
||||
}
|
||||
}
|
||||
|
||||
errors.push(...validateExampleResolution())
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error('verify-cordis-config: Loader entry metadata is static; move !!js under plugin config or select an explicit overlay.')
|
||||
console.error('verify-cordis-config: invalid Loader metadata or example package resolution:')
|
||||
for (const error of errors) console.error(`- ${error}`)
|
||||
process.exitCode = 1
|
||||
} else {
|
||||
@@ -55,6 +72,7 @@ function validateEntry(value: unknown, file: string, path: string): void {
|
||||
errors.push(`${file}${path}: entry must be an object`)
|
||||
return
|
||||
}
|
||||
recordExamplePlugin(value, file)
|
||||
validateMetadata(value, file, path)
|
||||
if ((value.group === true || value.name === '@cordisjs/plugin-group') && isUnknownArray(value.config)) {
|
||||
for (let index = 0; index < value.config.length; index++) {
|
||||
@@ -68,6 +86,7 @@ function validateEntry(value: unknown, file: string, path: string): void {
|
||||
const patch = config.patches[index]
|
||||
const patchPath = `${path}.config.patches[${index}]`
|
||||
if (!isRecord(patch)) continue
|
||||
recordExamplePlugin(patch, file)
|
||||
validateMetadata(patch, file, patchPath)
|
||||
if (!isUnknownArray(patch.insert)) continue
|
||||
for (let insertIndex = 0; insertIndex < patch.insert.length; insertIndex++) {
|
||||
@@ -76,6 +95,97 @@ function validateEntry(value: unknown, file: string, path: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function recordExamplePlugin(entry: Record<string, unknown>, file: string): void {
|
||||
if (file.startsWith('examples/') && typeof entry.name === 'string') {
|
||||
examplePluginReferences.push({ file, name: entry.name })
|
||||
}
|
||||
}
|
||||
|
||||
function validateExampleResolution(): string[] {
|
||||
const violations: string[] = []
|
||||
const exampleManifest = readManifest('examples/package.json')
|
||||
const dependencies = exampleManifest.dependencies ?? {}
|
||||
const localPackages = localPackageDirectories()
|
||||
const rootReferences = rootProjectReferences()
|
||||
const requiredPackages = new Map<string, Set<string>>()
|
||||
|
||||
for (const reference of examplePluginReferences) {
|
||||
const packageName = packageNameFromSpecifier(reference.name)
|
||||
if (packageName === undefined) continue
|
||||
const locations = requiredPackages.get(packageName) ?? new Set<string>()
|
||||
locations.add(reference.file)
|
||||
requiredPackages.set(packageName, locations)
|
||||
}
|
||||
|
||||
for (const [packageName, locations] of requiredPackages) {
|
||||
if (!(packageName in dependencies)) {
|
||||
violations.push(`${[...locations].join(', ')}: ${packageName} must be declared in examples/package.json dependencies`)
|
||||
}
|
||||
}
|
||||
|
||||
const localExamplePackages = new Set([
|
||||
...Object.keys(dependencies),
|
||||
...requiredPackages.keys(),
|
||||
])
|
||||
for (const packageName of localExamplePackages) {
|
||||
const packageDirectory = localPackages.get(packageName)
|
||||
if (packageDirectory === undefined || rootReferences.has(packageDirectory)) continue
|
||||
const repoPath = relative(root, packageDirectory).replaceAll('\\', '/')
|
||||
violations.push(`tsconfig.json: missing project reference for ${packageName} (${repoPath})`)
|
||||
}
|
||||
|
||||
return violations
|
||||
}
|
||||
|
||||
function readManifest(path: string): PackageManifest {
|
||||
return JSON.parse(readFileSync(resolve(root, path), 'utf8')) as PackageManifest
|
||||
}
|
||||
|
||||
function localPackageDirectories(): Map<string, string> {
|
||||
const manifests = globSync(['packages/*/*/package.json', 'vendor/*/package.json'], { cwd: root })
|
||||
const packages = new Map<string, string>()
|
||||
for (const manifestPath of manifests) {
|
||||
const manifest = readManifest(manifestPath)
|
||||
if (manifest.name !== undefined) packages.set(manifest.name, resolve(root, dirname(manifestPath)))
|
||||
}
|
||||
return packages
|
||||
}
|
||||
|
||||
function rootProjectReferences(): Set<string> {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
return collected
|
||||
}
|
||||
|
||||
function packageNameFromSpecifier(specifier: string): string | undefined {
|
||||
if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('file:')) return undefined
|
||||
const segments = specifier.split('/')
|
||||
if (specifier.startsWith('@')) {
|
||||
return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined
|
||||
}
|
||||
return segments[0] || undefined
|
||||
}
|
||||
|
||||
function validateMetadata(entry: Record<string, unknown>, file: string, path: string): void {
|
||||
for (const field of metadataFields) {
|
||||
if (!(field in entry)) continue
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Verify root-relative `docs/*.md` tokens in repo-authored TypeScript. The
|
||||
* textual scan requires the extension, checks matching string literals too,
|
||||
* and excludes built declarations and vendored source.
|
||||
* Verify root-relative documentation paths in repo-authored TypeScript. The
|
||||
* textual scan covers `docs/*.md` and `.agents/notes/*.md`, requires the
|
||||
* extension, checks matching string literals too, and excludes built
|
||||
* declarations and vendored source.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
@@ -18,9 +19,9 @@ const isExcluded = (p: string): boolean =>
|
||||
p.includes('/lib/') || p.endsWith('.d.ts') || p.startsWith('vendor/')
|
||||
|
||||
/** Root-relative Markdown path token, excluding trailing prose. */
|
||||
const DOC_REF = /\bdocs\/[A-Za-z0-9._/-]+\.md/g
|
||||
const DOC_REF = /(?:\bdocs|\.agents\/notes)\/[A-Za-z0-9._/-]+\.md/g
|
||||
|
||||
/** Find every broken `docs/….md` reference in one TypeScript file. */
|
||||
/** Find every broken root-relative documentation reference in one TypeScript file. */
|
||||
function findViolations(absPath: string): Violation[] {
|
||||
return findReferenceViolations(root, absPath, DOC_REF, ref => ref, ref => !existsSync(resolve(root, ref)))
|
||||
}
|
||||
@@ -30,11 +31,11 @@ const all = files.flatMap(file => findViolations(file.abs))
|
||||
const checked = files.length
|
||||
|
||||
if (all.length === 0) {
|
||||
console.log(`verify-doc-refs: ${checked} file(s) checked, all docs/*.md references resolve.`)
|
||||
console.log(`verify-doc-refs: ${checked} file(s) checked, all documentation references resolve.`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.error('verify-doc-refs: broken docs/*.md references found in source comments (target does not exist):')
|
||||
console.error('verify-doc-refs: broken documentation references found in source comments (target does not exist):')
|
||||
for (const v of all) {
|
||||
console.error(` ${v.file}:${v.line} ${v.ref}`)
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
* re-exports keep their docs at the declaring contract. Unknown forms fail closed.
|
||||
*/
|
||||
|
||||
import { existsSync, globSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { existsSync, globSync, readFileSync } from 'node:fs'
|
||||
import { relative, resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc } from './jsdoc.ts'
|
||||
|
||||
@@ -389,7 +389,13 @@ function checkDecl(
|
||||
* @param w - the walk state violations append to.
|
||||
* @param ambient - whether this scope is ambient (`declare` namespace or a declaration file), where members export implicitly.
|
||||
*/
|
||||
function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk, ambient: boolean): void {
|
||||
function checkScope(
|
||||
statements: readonly ts.Statement[],
|
||||
prefix: string,
|
||||
w: Walk,
|
||||
ambient: boolean,
|
||||
allowedNames?: ReadonlySet<string>,
|
||||
): void {
|
||||
const byName = new Map<string, ts.Statement[]>()
|
||||
const overloadSigs = new Set<string>()
|
||||
const add = (name: string, stmt: ts.Statement): void => {
|
||||
@@ -455,7 +461,20 @@ function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (isExported(stmt) || (ambient && !ts.isImportDeclaration(stmt))) request(stmt, null)
|
||||
if (isExported(stmt) || (ambient && !ts.isImportDeclaration(stmt))) {
|
||||
if (allowedNames === undefined) {
|
||||
request(stmt, null)
|
||||
} else if (ts.isVariableStatement(stmt)) {
|
||||
for (const declaration of stmt.declarationList.declarations) {
|
||||
if (ts.isIdentifier(declaration.name) && allowedNames.has(declaration.name.text)) {
|
||||
request(stmt, declaration.name.text)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const name = declarationName(stmt) ?? 'default'
|
||||
if (allowedNames.has(name)) request(stmt, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const stmt of statements) {
|
||||
const only = requested.get(stmt)
|
||||
@@ -463,6 +482,67 @@ function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk
|
||||
}
|
||||
}
|
||||
|
||||
function exportedTargets(value: unknown): string[] {
|
||||
if (typeof value === 'string') return [value]
|
||||
if (!value || typeof value !== 'object') return []
|
||||
return Object.values(value).flatMap(exportedTargets)
|
||||
}
|
||||
|
||||
function sourceEntry(target: string): string | undefined {
|
||||
if (target.startsWith('./lib/types/') && target.endsWith('.d.ts')) {
|
||||
return `src/${target.slice('./lib/types/'.length, -'.d.ts'.length)}.ts`
|
||||
}
|
||||
if (target.startsWith('./lib/') && target.endsWith('.js')) {
|
||||
return `src/${target.slice('./lib/'.length, -'.js'.length)}.ts`
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function declarationName(declaration: ts.Node): string | undefined {
|
||||
const name = (declaration as ts.NamedDeclaration).name
|
||||
if (name && ts.isIdentifier(name)) return name.text
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Resolve the declarations reachable through packages that do not export src/*. */
|
||||
function restrictedPublicNames(
|
||||
scanRoot: string,
|
||||
rels: readonly string[],
|
||||
program: ts.Program,
|
||||
checker: ts.TypeChecker,
|
||||
): { restrictedPackages: Set<string>; namesByFile: Map<string, Set<string>> } {
|
||||
const restrictedPackages = new Set<string>()
|
||||
const namesByFile = new Map<string, Set<string>>()
|
||||
const packages = new Set(rels.map(rel => rel.split('/').slice(0, 3).join('/')))
|
||||
for (const packageDir of packages) {
|
||||
const manifestPath = resolve(scanRoot, packageDir, 'package.json')
|
||||
if (!existsSync(manifestPath)) continue
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { exports?: Record<string, unknown> }
|
||||
if (!manifest.exports || manifest.exports['./src/*'] !== undefined) continue
|
||||
restrictedPackages.add(packageDir)
|
||||
const entries = new Set(Object.values(manifest.exports).flatMap(exportedTargets).flatMap((target) => {
|
||||
const entry = sourceEntry(target)
|
||||
return entry ? [`${packageDir}/${entry}`] : []
|
||||
}))
|
||||
for (const entry of entries) {
|
||||
const source = program.getSourceFile(resolve(scanRoot, entry))
|
||||
const moduleSymbol = source && checker.getSymbolAtLocation(source)
|
||||
if (!source || !moduleSymbol) continue
|
||||
for (const exported of checker.getExportsOfModule(moduleSymbol)) {
|
||||
const target = (exported.flags & ts.SymbolFlags.Alias) !== 0 ? checker.getAliasedSymbol(exported) : exported
|
||||
for (const declaration of target.declarations ?? []) {
|
||||
const name = declarationName(declaration)
|
||||
const file = declaration.getSourceFile().fileName
|
||||
const rel = relative(scanRoot, file).split(sep).join('/')
|
||||
if (!name || !rel.startsWith(`${packageDir}/src/`)) continue
|
||||
namesByFile.set(rel, new Set([...(namesByFile.get(rel) ?? []), name]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { restrictedPackages, namesByFile }
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiler options for the walk's program.
|
||||
*
|
||||
@@ -495,15 +575,26 @@ function loadCompilerOptions(scanRoot: string): ts.CompilerOptions {
|
||||
*/
|
||||
export function collectExportJsdocViolations(scanRoot: string = root): string[] {
|
||||
const violations: string[] = []
|
||||
const rels = globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).sort()
|
||||
const rels = globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot })
|
||||
.map(path => path.split(sep).join('/'))
|
||||
.sort()
|
||||
const program = ts.createProgram(rels.map(rel => resolve(scanRoot, rel)), loadCompilerOptions(scanRoot))
|
||||
const checker = program.getTypeChecker()
|
||||
const { restrictedPackages, namesByFile } = restrictedPublicNames(scanRoot, rels, program, checker)
|
||||
for (const rel of rels) {
|
||||
const sf = program.getSourceFile(resolve(scanRoot, rel))
|
||||
if (!sf) continue // program root files always resolve; guard for narrowing
|
||||
// A script-style declaration file (no imports/exports) is one big ambient
|
||||
// scope; a module-style .d.ts still honors explicit export modifiers.
|
||||
checkScope(sf.statements, '', { rel, sf, text: sf.text, checker, violations }, sf.isDeclarationFile && !ts.isExternalModule(sf))
|
||||
const packageDir = rel.split('/').slice(0, 3).join('/')
|
||||
const allowedNames = restrictedPackages.has(packageDir) ? namesByFile.get(rel) ?? new Set<string>() : undefined
|
||||
checkScope(
|
||||
sf.statements,
|
||||
'',
|
||||
{ rel, sf, text: sf.text, checker, violations },
|
||||
sf.isDeclarationFile && !ts.isExternalModule(sf),
|
||||
allowedNames,
|
||||
)
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ const root = resolve(import.meta.dirname, '..')
|
||||
const PATTERNS = [
|
||||
'README.md',
|
||||
'README.zh.md',
|
||||
'.agents/notes/**/*.md',
|
||||
'docs/**/*.md',
|
||||
'packages/*/*.md',
|
||||
'packages/*/*/*.md',
|
||||
|
||||
@@ -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'
|
||||
@@ -13,15 +14,16 @@ import { uniqueRepoFiles } from './repo-files.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
/** Files to check: doc-typecheck's scope, prompt goldens, and the AGENTS.md pair. */
|
||||
/** Files to check: doc-typecheck's scope, system-prompt expected outputs, and the AGENTS.md pair. */
|
||||
const PATTERNS = [
|
||||
'README.md',
|
||||
'README.zh.md',
|
||||
'.agents/notes/**/*.md',
|
||||
'docs/**/*.md',
|
||||
'packages/*/*.md',
|
||||
'packages/*/*/*.md',
|
||||
'examples/**/system-prompt.golden.md',
|
||||
'packages/**/system-prompt.golden.md',
|
||||
'examples/**/system-prompt.expected.md',
|
||||
'packages/**/system-prompt.expected.md',
|
||||
'AGENTS.md',
|
||||
'packages/AGENTS.md',
|
||||
]
|
||||
@@ -34,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 => {
|
||||
|
||||
@@ -17,6 +17,7 @@ const root = resolve(import.meta.dirname, '..')
|
||||
const PATTERNS = [
|
||||
'README.md',
|
||||
'README.zh.md',
|
||||
'.agents/notes/**/*.md',
|
||||
'docs/**/*.md',
|
||||
'packages/*/*.md',
|
||||
'packages/*/*/*.md',
|
||||
@@ -78,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.`)
|
||||
@@ -14,6 +14,7 @@ const root = resolve(import.meta.dirname, '..')
|
||||
/** Markdown + repo-authored TypeScript that may cite package paths. */
|
||||
const PATTERNS = [
|
||||
'README.md',
|
||||
'.agents/notes/**/*.md',
|
||||
'docs/**/*.md',
|
||||
'packages/*/*.md',
|
||||
'packages/*/*/*.md',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Doc-sync gate for the canonical package-README limitations section. It scans
|
||||
* package manifests, rejects missing or variant sections, and requires one
|
||||
* top-level bullet; audited packages in {@link NO_LIMITATIONS} must omit it.
|
||||
* See the [limitations RFC](../docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md).
|
||||
* See the [limitations Agent Note](../.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.md).
|
||||
*/
|
||||
|
||||
import { existsSync, globSync, readFileSync } from 'node:fs'
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Doc-sync gate for package README Model Experience sections. It validates
|
||||
* audited package classifications, context-surface fields, package-owned text
|
||||
* blocks, generated-catalog links, and final-section order. See the
|
||||
* [Model Experience RFC](../docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md).
|
||||
* audited package classifications, model/token/KV-cache fields, package-owned
|
||||
* text blocks, generated-catalog links, and final-section order. See the
|
||||
* [Model Experience Agent Note](../.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.md).
|
||||
*/
|
||||
|
||||
import { existsSync, globSync, readFileSync } from 'node:fs'
|
||||
@@ -12,8 +12,10 @@ import { markdownHeadingLines, markdownProseLines, type MarkdownProseLine } from
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const HEADING = '## Model Experience'
|
||||
const LIMITATIONS_HEADING = '## Known Limitations and Deferred Work'
|
||||
const MODEL_VIEW_LABEL = '**What the model sees**'
|
||||
const TOKEN_EFFECT_LABEL = '**Token effect**'
|
||||
const MODEL_VIEW_HEADING = '#### What the model sees'
|
||||
const TOKEN_EFFECT_HEADING = '#### Token effect'
|
||||
const KV_CACHE_EFFECT_HEADING = '#### KV Cache effect'
|
||||
const FIELD_HEADINGS = [MODEL_VIEW_HEADING, TOKEN_EFFECT_HEADING, KV_CACHE_EFFECT_HEADING] as const
|
||||
|
||||
type SentenceKind = 'none' | 'indirect'
|
||||
|
||||
@@ -30,40 +32,69 @@ interface SentenceContract {
|
||||
const NO_MODEL_EXPERIENCE_SECTION: Readonly<Record<string, string>> = {
|
||||
'packages/core/scope': 'The package is a model-agnostic registration and lifecycle primitive; model-facing consumers own any context selection.',
|
||||
'packages/util/brand': 'The package is a type-only primitive erased at compile time.',
|
||||
'packages/util/paths': 'The package only resolves harness-owned host paths; model-facing consumers own any rendered use.',
|
||||
}
|
||||
|
||||
/**
|
||||
* Packages whose Model Experience is simple enough for one gated sentence.
|
||||
* Every other package must carry canonical context-surface blocks. A package
|
||||
* moves on or off this list with the change to its context behavior.
|
||||
* Packages whose Model Experience is simple enough for one gated sentence plus
|
||||
* a KV-cache field. Every other package must carry canonical context-surface
|
||||
* blocks. A package moves on or off this list with its context behavior.
|
||||
*/
|
||||
const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/bash/bash': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' },
|
||||
'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.' },
|
||||
'packages/sdk/helper': { kind: 'none', reason: 'The project domain edits files and registers no live agent or model surface.' },
|
||||
'packages/sdk/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' },
|
||||
'packages/sdk/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or model surface.' },
|
||||
'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' },
|
||||
'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' },
|
||||
'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' },
|
||||
'packages/spill/spill': { kind: 'indirect', reason: 'The storage seam delegates model rendering to spill consumers.' },
|
||||
'packages/spill/spill-local': { kind: 'indirect', reason: 'The storage backend delegates model rendering to spill consumers.' },
|
||||
'packages/subagent/subagent': { kind: 'indirect', reason: 'The provider registry delegates parent-model rendering to dsh-tool-subagent.' },
|
||||
'packages/subagent/subagent-subprocess': { kind: 'indirect', reason: 'Only process-based subagent backends compose a child model request.' },
|
||||
'packages/support/acp-snapshot': { kind: 'none', reason: 'The test harness observes and normalizes transcripts without changing live requests.' },
|
||||
'packages/support/agent-loop-testkit': { kind: 'none', reason: 'The test helper mounts services but neither drives nor modifies model requests.' },
|
||||
'packages/support/invariants': { kind: 'none', reason: 'The observer validates requests but never rewrites their context.' },
|
||||
'packages/support/loader-smoke': { kind: 'none', reason: 'The test harness observes child-process streams without changing live requests.' },
|
||||
'packages/support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' },
|
||||
'packages/support/subagent-mock': { kind: 'indirect', reason: 'Only dsh-tool-subagent renders its configured test outcome.' },
|
||||
'packages/tasks/tasks': { kind: 'indirect', reason: 'Producer and control-surface plugins own all model rendering over the task registry.' },
|
||||
'packages/examples/acp-demo': { kind: 'indirect', reason: 'The app bundle delegates request composition to dsh-agent-spine-demo and dsh-acp.' },
|
||||
'packages/ui/app-boot': { kind: 'indirect', reason: 'Only the loaded plugin tree contributes model context.' },
|
||||
'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/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.' },
|
||||
'packages/web/web-fetch-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' },
|
||||
'packages/web/web-search-exa': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' },
|
||||
@@ -81,35 +112,41 @@ interface ContextSurface {
|
||||
heading: Line
|
||||
modelView: Line
|
||||
tokenEffect: Line
|
||||
kvCacheEffect: Line
|
||||
title: string
|
||||
modelViewVerbatimBlocks: number
|
||||
verbatimBlocks: number
|
||||
}
|
||||
|
||||
/** Validate H4-plus-markdown literals nested after one context surface's fields. */
|
||||
function validateNestedVerbatim(raw: readonly string[]): { blocks: number; error?: string } {
|
||||
interface ParsedField {
|
||||
value: Line
|
||||
verbatimBlocks: number
|
||||
}
|
||||
|
||||
/** Validate H5-plus-markdown literals nested under one Model Experience field. */
|
||||
function validateNestedVerbatim(raw: readonly string[], fragments: Set<string>): { blocks: number; error?: string } {
|
||||
let cursor = 0
|
||||
while (raw[cursor]?.trim().length === 0) cursor += 1
|
||||
if (cursor === raw.length) return { blocks: 0 }
|
||||
|
||||
let blocks = 0
|
||||
const fragments = new Set<string>()
|
||||
while (true) {
|
||||
while (raw[cursor]?.trim().length === 0) cursor += 1
|
||||
if (cursor === raw.length) break
|
||||
if (!/^#### \S/.test(raw[cursor] ?? '')) {
|
||||
return { blocks, error: 'content after Token effect must be a titled H4 verbatim block' }
|
||||
if (!/^##### \S/.test(raw[cursor] ?? '')) {
|
||||
return { blocks, error: 'content after a field paragraph must be a titled H5 verbatim block' }
|
||||
}
|
||||
const title = (raw[cursor] as string).slice('#### '.length)
|
||||
const title = (raw[cursor] as string).slice('##### '.length)
|
||||
const fragment = headingFragment(title)
|
||||
if (fragment.length === 0) return { blocks, error: 'verbatim H4 title must be non-empty' }
|
||||
if (fragment.length === 0) return { blocks, error: 'verbatim H5 title must be non-empty' }
|
||||
if (fragments.has(fragment)) {
|
||||
return { blocks, error: `verbatim H4 title ${JSON.stringify(title)} is duplicated within its context surface` }
|
||||
return { blocks, error: `verbatim H5 title ${JSON.stringify(title)} is duplicated within its context surface` }
|
||||
}
|
||||
fragments.add(fragment)
|
||||
cursor += 1
|
||||
while (raw[cursor]?.trim().length === 0) cursor += 1
|
||||
if (raw[cursor] !== '```markdown') {
|
||||
return { blocks, error: 'each nested verbatim H4 requires an exact ```markdown fence' }
|
||||
return { blocks, error: 'each nested verbatim H5 requires an exact ```markdown fence' }
|
||||
}
|
||||
cursor += 1
|
||||
const contentStart = cursor
|
||||
@@ -122,7 +159,7 @@ function validateNestedVerbatim(raw: readonly string[]): { blocks: number; error
|
||||
return { blocks }
|
||||
}
|
||||
|
||||
/** GitHub-style fragment for the simple ASCII H4 titles allowed by this contract. */
|
||||
/** GitHub-style fragment for the simple ASCII nested titles allowed by this contract. */
|
||||
function headingFragment(title: string): string {
|
||||
return title.toLowerCase().replaceAll('`', '').replaceAll(/[^a-z0-9 _-]/g, '').trim().replaceAll(/\s+/g, '-')
|
||||
}
|
||||
@@ -155,6 +192,7 @@ let indirectCount = 0
|
||||
let verbatimBlockCount = 0
|
||||
let systemPromptSurfaceCount = 0
|
||||
let toolSchemaSurfaceCount = 0
|
||||
let kvCacheEffectCount = 0
|
||||
|
||||
for (const [pkg, reason] of Object.entries(NO_MODEL_EXPERIENCE_SECTION)) {
|
||||
if (!scannedPackages.has(pkg)) {
|
||||
@@ -248,13 +286,31 @@ for (const packageJson of packageJsons) {
|
||||
if (sentenceContract !== undefined) {
|
||||
const pattern = sentenceContract.kind === 'none' ? /^None, as .+\.$/ : /^Indirectly, through .+\.$/
|
||||
const rawContent = rawSection.filter(line => line.trim().length > 0)
|
||||
if (content.length !== 1 || rawContent.length !== 1 || !pattern.test(content[0]?.raw ?? '')) {
|
||||
const sentence = content[0]
|
||||
const kvCacheHeading = content[1]
|
||||
const kvCacheEffect = content[2]
|
||||
if (content.length !== 3 || rawContent.length !== 3 || !pattern.test(sentence?.raw ?? '')) {
|
||||
const prefix = sentenceContract.kind === 'none' ? 'None, as ' : 'Indirectly, through '
|
||||
failures.push({ path: readme, message: `must contain exactly one sentence beginning ${JSON.stringify(prefix)} and ending with a period` })
|
||||
failures.push({ path: readme, message: `must contain exactly one sentence beginning ${JSON.stringify(prefix)} and ending with a period, followed by ${KV_CACHE_EFFECT_HEADING} and one non-empty paragraph` })
|
||||
continue
|
||||
}
|
||||
if (kvCacheHeading?.raw !== KV_CACHE_EFFECT_HEADING
|
||||
|| kvCacheEffect === undefined
|
||||
|| /^#{1,6} /.test(kvCacheEffect.raw)
|
||||
|| kvCacheEffect.raw.trim().length === 0) {
|
||||
failures.push({ path: readme, message: `line ${kvCacheHeading?.index ?? sentence?.index ?? modelHeading.index}: short Model Experience form requires exact ${KV_CACHE_EFFECT_HEADING} and one non-empty paragraph` })
|
||||
continue
|
||||
}
|
||||
if (sentence === undefined
|
||||
|| sentence.index !== modelHeading.index + 2
|
||||
|| kvCacheHeading.index !== sentence.index + 2
|
||||
|| kvCacheEffect.index !== kvCacheHeading.index + 2) {
|
||||
failures.push({ path: readme, message: 'short Model Experience sentence, KV-cache H4, and paragraph require one blank line between each element' })
|
||||
continue
|
||||
}
|
||||
if (sentenceContract.kind === 'none') explainedNoneCount += 1
|
||||
else indirectCount += 1
|
||||
kvCacheEffectCount += 1
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -280,8 +336,6 @@ for (const packageJson of packageJsons) {
|
||||
const end = surfaceStarts[surfaceIndex + 1]?.index ?? content.length
|
||||
const entries = content.slice(start.index, end)
|
||||
const heading = entries[0] as Line
|
||||
const modelView = entries[1]
|
||||
const tokenEffect = entries[2]
|
||||
const title = heading.raw.slice('### '.length)
|
||||
const fragment = headingFragment(title)
|
||||
if (fragment.length === 0) {
|
||||
@@ -294,56 +348,100 @@ for (const packageJson of packageJsons) {
|
||||
surfaceError = true
|
||||
break
|
||||
}
|
||||
if (modelView === undefined || !modelView.raw.startsWith(`${MODEL_VIEW_LABEL}: `) || modelView.raw.slice(`${MODEL_VIEW_LABEL}: `.length).trim().length === 0) {
|
||||
failures.push({ path: readme, message: `line ${modelView?.index ?? heading.index}: context surface requires non-empty ${MODEL_VIEW_LABEL}: text` })
|
||||
surfaceError = true
|
||||
break
|
||||
}
|
||||
if (tokenEffect === undefined || !tokenEffect.raw.startsWith(`${TOKEN_EFFECT_LABEL}: `) || tokenEffect.raw.slice(`${TOKEN_EFFECT_LABEL}: `.length).trim().length === 0) {
|
||||
failures.push({ path: readme, message: `line ${tokenEffect?.index ?? heading.index}: context surface requires non-empty ${TOKEN_EFFECT_LABEL}: text` })
|
||||
const fieldStarts = entries
|
||||
.map((line, index) => ({ line, index }))
|
||||
.filter(entry => /^#### \S/.test(entry.line.raw))
|
||||
if (fieldStarts.length !== FIELD_HEADINGS.length || fieldStarts[0]?.index !== 1) {
|
||||
failures.push({ path: readme, message: `line ${heading.index}: context surface requires exactly three ordered H4 fields: ${FIELD_HEADINGS.join(', ')}` })
|
||||
surfaceError = true
|
||||
break
|
||||
}
|
||||
if ((surfaceIndex === 0 && heading.index !== modelHeading.index + 2)
|
||||
|| rawLines[heading.index - 2]?.trim().length !== 0
|
||||
|| modelView.index !== heading.index + 2
|
||||
|| tokenEffect.index !== modelView.index + 2) {
|
||||
failures.push({ path: readme, message: `line ${heading.index}: context-surface heading and fields require one blank line between each element` })
|
||||
|| fieldStarts[0].line.index !== heading.index + 2) {
|
||||
failures.push({ path: readme, message: `line ${heading.index}: context-surface heading and first field require one blank line between them` })
|
||||
surfaceError = true
|
||||
break
|
||||
}
|
||||
const unexpected = entries.slice(3).find(line => !/^#### \S/.test(line.raw))
|
||||
if (unexpected !== undefined) {
|
||||
failures.push({ path: readme, message: `line ${unexpected.index}: content after ${TOKEN_EFFECT_LABEL} must be a titled H4 plus \`markdown\` fence inside this context surface` })
|
||||
surfaceError = true
|
||||
break
|
||||
const parsedFields: ParsedField[] = []
|
||||
const verbatimFragments = new Set<string>()
|
||||
for (let fieldIndex = 0; fieldIndex < FIELD_HEADINGS.length; fieldIndex += 1) {
|
||||
const fieldStart = fieldStarts[fieldIndex] as { line: Line; index: number }
|
||||
const expectedHeading = FIELD_HEADINGS[fieldIndex] as string
|
||||
if (fieldStart.line.raw !== expectedHeading) {
|
||||
failures.push({ path: readme, message: `line ${fieldStart.line.index}: expected exact field heading ${JSON.stringify(expectedHeading)}, found ${JSON.stringify(fieldStart.line.raw)}` })
|
||||
surfaceError = true
|
||||
break
|
||||
}
|
||||
const fieldEnd = fieldStarts[fieldIndex + 1]?.index ?? entries.length
|
||||
const fieldEntries = entries.slice(fieldStart.index, fieldEnd)
|
||||
const value = fieldEntries[1]
|
||||
if (value === undefined || /^#{1,6} /.test(value.raw) || value.raw.trim().length === 0) {
|
||||
failures.push({ path: readme, message: `line ${fieldStart.line.index}: ${expectedHeading} requires one non-empty paragraph` })
|
||||
surfaceError = true
|
||||
break
|
||||
}
|
||||
if (value.index !== fieldStart.line.index + 2) {
|
||||
failures.push({ path: readme, message: `line ${fieldStart.line.index}: ${expectedHeading} and its paragraph require one blank line between them` })
|
||||
surfaceError = true
|
||||
break
|
||||
}
|
||||
const unexpected = fieldEntries.slice(2).find(line => !/^##### \S/.test(line.raw))
|
||||
if (unexpected !== undefined) {
|
||||
failures.push({ path: readme, message: `line ${unexpected.index}: content after ${expectedHeading} paragraph must be a titled H5 plus \`markdown\` fence owned by that field` })
|
||||
surfaceError = true
|
||||
break
|
||||
}
|
||||
const nextHeadingLine = fieldStarts[fieldIndex + 1]?.line.index
|
||||
?? surfaceStarts[surfaceIndex + 1]?.line.index
|
||||
?? nextH2Line
|
||||
if (rawLines[nextHeadingLine - 2]?.trim().length !== 0) {
|
||||
failures.push({ path: readme, message: `line ${nextHeadingLine}: Model Experience headings require a preceding blank line` })
|
||||
surfaceError = true
|
||||
break
|
||||
}
|
||||
const verbatim = validateNestedVerbatim(rawLines.slice(value.index, nextHeadingLine - 1), verbatimFragments)
|
||||
if (verbatim.error !== undefined) {
|
||||
failures.push({ path: readme, message: `line ${value.index}: ${verbatim.error}` })
|
||||
surfaceError = true
|
||||
break
|
||||
}
|
||||
if (fieldEntries.length - 2 !== verbatim.blocks) {
|
||||
failures.push({ path: readme, message: `line ${value.index}: every nested H5 must own exactly one \`markdown\` fence` })
|
||||
surfaceError = true
|
||||
break
|
||||
}
|
||||
parsedFields.push({ value, verbatimBlocks: verbatim.blocks })
|
||||
}
|
||||
const nextHeadingLine = surfaceStarts[surfaceIndex + 1]?.line.index ?? nextH2Line
|
||||
const verbatim = validateNestedVerbatim(rawLines.slice(tokenEffect.index, nextHeadingLine - 1))
|
||||
if (verbatim.error !== undefined) {
|
||||
failures.push({ path: readme, message: `line ${tokenEffect.index}: ${verbatim.error}` })
|
||||
surfaceError = true
|
||||
break
|
||||
}
|
||||
if (entries.length - 3 !== verbatim.blocks) {
|
||||
failures.push({ path: readme, message: `line ${tokenEffect.index}: every nested H4 must own exactly one \`markdown\` fence` })
|
||||
surfaceError = true
|
||||
break
|
||||
}
|
||||
if (/\]\(#[^)]+\)/.test(modelView.raw) || /\]\(#[^)]+\)/.test(tokenEffect.raw)) {
|
||||
failures.push({ path: readme, message: `line ${heading.index}: Model Experience fields must not link between local subsections; nest the H4 in its owning H3` })
|
||||
if (surfaceError) break
|
||||
const modelViewField = parsedFields[0] as ParsedField
|
||||
const tokenEffectField = parsedFields[1] as ParsedField
|
||||
const kvCacheEffectField = parsedFields[2] as ParsedField
|
||||
const modelView = modelViewField.value
|
||||
const tokenEffect = tokenEffectField.value
|
||||
const kvCacheEffect = kvCacheEffectField.value
|
||||
if (/\]\(#[^)]+\)/.test(modelView.raw) || /\]\(#[^)]+\)/.test(tokenEffect.raw) || /\]\(#[^)]+\)/.test(kvCacheEffect.raw)) {
|
||||
failures.push({ path: readme, message: `line ${heading.index}: Model Experience fields must not link between local subsections; nest the H5 in its owning H4 field` })
|
||||
surfaceError = true
|
||||
break
|
||||
}
|
||||
surfaceFragments.add(fragment)
|
||||
surfaces.push({ heading, modelView, tokenEffect, title, verbatimBlocks: verbatim.blocks })
|
||||
surfaces.push({
|
||||
heading,
|
||||
modelView,
|
||||
tokenEffect,
|
||||
kvCacheEffect,
|
||||
title,
|
||||
modelViewVerbatimBlocks: modelViewField.verbatimBlocks,
|
||||
verbatimBlocks: parsedFields.reduce((total, field) => total + field.verbatimBlocks, 0),
|
||||
})
|
||||
}
|
||||
if (surfaceError) continue
|
||||
|
||||
const promptWithoutVerbatim = surfaces.find(surface => isDirectSystemPromptSurface(surface.title)
|
||||
&& surface.verbatimBlocks === 0)
|
||||
&& surface.modelViewVerbatimBlocks === 0)
|
||||
if (promptWithoutVerbatim !== undefined) {
|
||||
failures.push({ path: readme, message: `line ${promptWithoutVerbatim.heading.index}: system-prompt surface must contain a titled H4 plus verbatim \`markdown\` block` })
|
||||
failures.push({ path: readme, message: `line ${promptWithoutVerbatim.heading.index}: system-prompt surface must contain a titled H5 plus verbatim \`markdown\` block under ${MODEL_VIEW_HEADING}` })
|
||||
continue
|
||||
}
|
||||
const hasConcreteLiteral = surfaces.some(surface => surface.verbatimBlocks > 0
|
||||
@@ -375,11 +473,12 @@ for (const packageJson of packageJsons) {
|
||||
contextSurfaceCount += surfaces.length
|
||||
systemPromptSurfaceCount += surfaces.filter(surface => isDirectSystemPromptSurface(surface.title)).length
|
||||
toolSchemaSurfaceCount += surfaces.filter(surface => /\bschemas?\b/i.test(surface.title)).length
|
||||
kvCacheEffectCount += surfaces.length
|
||||
structuredCount += 1
|
||||
}
|
||||
|
||||
if (failures.length === 0) {
|
||||
console.log(`verify-package-readme-model-experience: ${packageJsons.length} README(s) checked (${omittedSectionCount} audited omissions, ${structuredCount} structured, ${contextSurfaceCount} context surfaces, ${systemPromptSurfaceCount} fenced system-prompt surfaces, ${toolSchemaSurfaceCount} catalog-linked tool-schema surfaces, ${explainedNoneCount} explained none, ${indirectCount} indirect, ${verbatimBlockCount} verbatim markdown blocks), all conform.`)
|
||||
console.log(`verify-package-readme-model-experience: ${packageJsons.length} README(s) checked (${omittedSectionCount} audited omissions, ${structuredCount} structured, ${contextSurfaceCount} context surfaces, ${kvCacheEffectCount} KV-cache fields, ${systemPromptSurfaceCount} fenced system-prompt surfaces, ${toolSchemaSurfaceCount} catalog-linked tool-schema surfaces, ${explainedNoneCount} explained none, ${indirectCount} indirect, ${verbatimBlockCount} verbatim markdown blocks), all conform.`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* Enforce RFC lifecycle/class paths, dated filenames, and titles; verify the
|
||||
* generated index and reject index rows in the curated README. Structural rules
|
||||
* and rendering are shared with `rfc-index.ts`; the closed classification
|
||||
* contract lives in `docs/rfc/README.md`.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { INDEX_ROW, renderIndex, rfcRoot, walkRfcTree } from './rfc-index.ts'
|
||||
|
||||
const { rfcs, errors } = walkRfcTree()
|
||||
|
||||
if (errors.length === 0) {
|
||||
let index: string | undefined
|
||||
try {
|
||||
index = readFileSync(resolve(rfcRoot, 'INDEX.md'), 'utf8')
|
||||
} catch {
|
||||
// A missing INDEX.md is reported below as staleness, exactly like a drifted one.
|
||||
}
|
||||
if (renderIndex(rfcs) !== index) {
|
||||
errors.push('index: docs/rfc/INDEX.md is stale or missing — run `pnpm run gen-rfc-index` and commit the result')
|
||||
}
|
||||
const readme = readFileSync(resolve(rfcRoot, 'README.md'), 'utf8')
|
||||
for (const line of readme.split('\n')) {
|
||||
if (INDEX_ROW.test(line)) {
|
||||
errors.push(`readme: index-shaped row in the curated README (the list lives in INDEX.md): ${JSON.stringify(line.slice(0, 80))}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length === 0) {
|
||||
console.log(`verify-rfc-classification: ${rfcs.length} RFC(s) checked, structure and index consistent.`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.error('verify-rfc-classification: violations found:')
|
||||
for (const e of errors) console.error(` ${e}`)
|
||||
process.exit(1)
|
||||
@@ -24,8 +24,18 @@ const root = resolve(import.meta.dirname, '..')
|
||||
const listMode = process.argv.includes('--list')
|
||||
const writeMode = process.argv.includes('--write')
|
||||
|
||||
/** Scope of the bilingual contract: the root README, the docs tree, and the Python SDK tree. */
|
||||
const SCOPE_PATTERNS = ['README.md', 'README.zh.md', 'README.i18n.yaml', 'docs/**/*.md', 'docs/**/*.i18n.yaml', 'python/**/*.md', 'python/**/*.i18n.yaml']
|
||||
/** Scope of the bilingual contract: root docs, Agent Notes, the docs tree, and the Python SDK tree. */
|
||||
const SCOPE_PATTERNS = [
|
||||
'README.md',
|
||||
'README.zh.md',
|
||||
'README.i18n.yaml',
|
||||
'.agents/notes/**/*.md',
|
||||
'.agents/notes/**/*.i18n.yaml',
|
||||
'docs/**/*.md',
|
||||
'docs/**/*.i18n.yaml',
|
||||
'python/**/*.md',
|
||||
'python/**/*.i18n.yaml',
|
||||
]
|
||||
|
||||
const manifest = parseTranslationPairingManifest(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8'))
|
||||
|
||||
@@ -121,8 +131,8 @@ for (const req of manifest.required) {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Date-named documents (RFCs) dated on/after the requiredSince cutoff merge
|
||||
// bilingual: a new RFC lands with its pair or not at all. Deterministic from
|
||||
// 2. Date-named documents (Agent Notes) dated on/after the requiredSince cutoff merge
|
||||
// bilingual: a new Agent Note lands with its pair or not at all. Deterministic from
|
||||
// the filename alone — no git history, so it holds on shallow CI checkouts.
|
||||
for (const source of sources) {
|
||||
if (isExcluded(source)) continue
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* Verify every `ts type-equiv` block against the source symbol named by the
|
||||
* manifest. Blocks and entries have a one-to-one relationship; comparison
|
||||
* ignores comments and whitespace but preserves declaration structure.
|
||||
* Verify every `ts type-equiv` and `ts public-api` block against the source
|
||||
* symbol named by the manifest. Ordinary entries preserve the complete
|
||||
* declaration; `public-api` entries preserve a class's body-stripped public
|
||||
* declaration. Blocks and entries have a one-to-one relationship; comparison
|
||||
* ignores whitespace and non-JSDoc comments but preserves declaration
|
||||
* structure and every original JSDoc comment.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync, existsSync } from 'node:fs'
|
||||
@@ -11,35 +14,35 @@ 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', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md']
|
||||
const MARKDOWN_GLOBS = ['README.md', '.agents/notes/**/*.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md']
|
||||
|
||||
/** One manifest entry: a documented type-equiv block and its source symbol. */
|
||||
/** One manifest entry: a source-equivalence block and its source symbol. */
|
||||
interface ManifestEntry {
|
||||
/** Doc file (repo-relative) containing the ` ```ts type-equiv ` block. */
|
||||
/** Doc file (repo-relative) containing the source-equivalence block. */
|
||||
doc: string
|
||||
/** The declared symbol the block must match (e.g. `SessionEvent`). */
|
||||
symbol: string
|
||||
/** Source file (repo-relative) that exports the symbol. */
|
||||
source: string
|
||||
/** Complete declaration (default), or a body-stripped public class API. */
|
||||
projection?: 'public-api'
|
||||
}
|
||||
|
||||
/** One extracted ` ```ts type-equiv ` block. */
|
||||
/** One extracted ` ```ts type-equiv ` or ` ```ts public-api ` block. */
|
||||
interface EquivBlock {
|
||||
doc: string
|
||||
/** 1-based line of the opening fence (for diagnostics). */
|
||||
line: number
|
||||
/** Symbol name parsed from the block's declaration. */
|
||||
symbol: string
|
||||
/** Complete declaration (default), or a body-stripped public class API. */
|
||||
projection?: 'public-api'
|
||||
/** Block body (the pasted declaration). */
|
||||
code: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove comments and normalize whitespace so prose-only edits do not drift
|
||||
* structural copies. This is intentionally not a general tokenizer: repo type
|
||||
* declarations do not contain comment delimiters inside string literals.
|
||||
*/
|
||||
function normalize(code: string): string {
|
||||
/** Normalize declaration structure independently of comments and whitespace. */
|
||||
function normalizeStructure(code: string): string {
|
||||
return code
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/(^|[^:])\/\/.*$/gm, '$1')
|
||||
@@ -47,23 +50,38 @@ function normalize(code: string): string {
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract normalized JSDoc comments in source order. Type declarations in this
|
||||
* repository do not contain comment delimiters inside string literals.
|
||||
*/
|
||||
function normalizeJSDoc(code: string): string[] {
|
||||
return [...code.matchAll(/\/\*\*[\s\S]*?\*\//g)]
|
||||
.map(match => match[0].replace(/\s+/g, ' ').trim())
|
||||
}
|
||||
|
||||
/** Strip source-only export modifiers. */
|
||||
function stripExport(code: string): string {
|
||||
return code.replace(/^export\s+(default\s+)?/, '')
|
||||
}
|
||||
|
||||
/** Parse the declared symbol name from a type-equiv block body. */
|
||||
/** Parse the declared symbol name from a source-equivalence block body. */
|
||||
function blockSymbol(code: string): string | null {
|
||||
const m = /(?:export\s+(?:default\s+)?)?(?:abstract\s+)?(?:interface|type|class|enum)\s+([A-Za-z0-9_]+)/.exec(code)
|
||||
return m?.[1] ?? null
|
||||
const sf = ts.createSourceFile('type-equiv.ts', code, ts.ScriptTarget.Latest, /* setParentNodes */ false, ts.ScriptKind.TS)
|
||||
for (const stmt of sf.statements) {
|
||||
const named =
|
||||
ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)
|
||||
|| ts.isClassDeclaration(stmt) || ts.isEnumDeclaration(stmt)
|
||||
if (named && stmt.name) return stmt.name.text
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Extract every ` ```ts type-equiv ` block from one Markdown file. */
|
||||
/** Extract every source-equivalence block from one Markdown file. */
|
||||
function extractEquivBlocks(docRel: string): EquivBlock[] {
|
||||
const text = readFileSync(resolve(root, docRel), 'utf8')
|
||||
const lines = text.split('\n')
|
||||
const blocks: EquivBlock[] = []
|
||||
let open: { line: number; body: string[] } | null = null
|
||||
let open: { line: number; body: string[]; projection?: 'public-api' } | null = null
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const raw = lines[i] ?? ''
|
||||
@@ -78,21 +96,33 @@ function extractEquivBlocks(docRel: string): EquivBlock[] {
|
||||
if (!symbol) {
|
||||
throw new Error(`verify-type-equiv: ${docRel}:${open.line} — type-equiv block has no parseable interface/type/class declaration`)
|
||||
}
|
||||
blocks.push({ doc: docRel, line: open.line, symbol, code })
|
||||
blocks.push({
|
||||
doc: docRel,
|
||||
line: open.line,
|
||||
symbol,
|
||||
code,
|
||||
...(open.projection === undefined ? {} : { projection: open.projection }),
|
||||
})
|
||||
open = null
|
||||
continue
|
||||
}
|
||||
if ((fence[2] ?? '').trim() === 'ts type-equiv') open = { line: i + 1, body: [] }
|
||||
const info = (fence[2] ?? '').trim()
|
||||
if (info === 'ts type-equiv public-api') {
|
||||
throw new Error(`verify-type-equiv: ${docRel}:${i + 1} — use the concise \`ts public-api\` fence`)
|
||||
}
|
||||
if (info === 'ts type-equiv') open = { line: i + 1, body: [] }
|
||||
if (info === 'ts public-api') open = { line: i + 1, body: [], projection: 'public-api' }
|
||||
}
|
||||
if (open) throw new Error(`verify-type-equiv: ${docRel}:${open.line} — unterminated type-equiv block`)
|
||||
return blocks
|
||||
}
|
||||
|
||||
/** The declaration text of `symbol` in `sourceRel`, with `export` stripped, or
|
||||
/**
|
||||
* The declaration text of `symbol` in `sourceRel`, with `export` stripped, or
|
||||
* null when the symbol is not declared there. Uses the TS parser so it spans
|
||||
* interfaces, type aliases (including mapped/generic ones), classes, and enums
|
||||
* uniformly, and excludes the leading JSDoc (getStart skips leading trivia)
|
||||
* while keeping inline member comments. */
|
||||
* uniformly while including declaration and member JSDoc.
|
||||
*/
|
||||
function sourceDeclaration(sourceRel: string, symbol: string): string | null {
|
||||
const abs = resolve(root, sourceRel)
|
||||
const text = readFileSync(abs, 'utf8')
|
||||
@@ -102,19 +132,89 @@ function sourceDeclaration(sourceRel: string, symbol: string): string | null {
|
||||
ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)
|
||||
|| ts.isClassDeclaration(stmt) || ts.isEnumDeclaration(stmt)
|
||||
if (named && stmt.name?.text === symbol) {
|
||||
return stripExport(stmt.getText(sf))
|
||||
const declarationStart = stmt.getStart(sf)
|
||||
const jsDoc = ts.getJSDocCommentsAndTags(stmt)
|
||||
.filter(ts.isJSDoc)
|
||||
.map(doc => text.slice(doc.pos, doc.end))
|
||||
.join('\n')
|
||||
const declaration = stripExport(text.slice(declarationStart, stmt.getEnd()))
|
||||
return jsDoc === '' ? declaration : `${jsDoc}\n${declaration}`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Leading source JSDoc attached to one declaration or member. */
|
||||
function sourceJSDoc(text: string, node: ts.Node): string {
|
||||
return ts.getJSDocCommentsAndTags(node)
|
||||
.filter(ts.isJSDoc)
|
||||
.map(doc => text.slice(doc.pos, doc.end))
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
/** Whether a class member is part of its public declaration. */
|
||||
function isPublicMember(member: ts.ClassElement): boolean {
|
||||
if (ts.isClassStaticBlockDeclaration(member)) return false
|
||||
const name = ts.getNameOfDeclaration(member)
|
||||
if (name && ts.isPrivateIdentifier(name)) return false
|
||||
const modifiers = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined
|
||||
return !(modifiers?.some(modifier =>
|
||||
modifier.kind === ts.SyntaxKind.PrivateKeyword
|
||||
|| modifier.kind === ts.SyntaxKind.ProtectedKeyword,
|
||||
) ?? false)
|
||||
}
|
||||
|
||||
/** Remove an implementation body while retaining the source signature. */
|
||||
function bodylessMember(text: string, sf: ts.SourceFile, member: ts.ClassElement): string {
|
||||
const start = member.getStart(sf)
|
||||
let end = member.end
|
||||
if (ts.isConstructorDeclaration(member) || ts.isMethodDeclaration(member)
|
||||
|| ts.isGetAccessorDeclaration(member) || ts.isSetAccessorDeclaration(member)) {
|
||||
if (member.body) end = member.body.getStart(sf)
|
||||
}
|
||||
if (ts.isPropertyDeclaration(member) && member.initializer) end = member.initializer.getStart(sf)
|
||||
const signature = text.slice(start, end).trimEnd().replace(/;$/, '').replace(/=\s*$/, '').trimEnd()
|
||||
return `${signature};`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a class as an ambient declaration containing only its public fields,
|
||||
* constructor, accessors, and methods. Implementation bodies and private or
|
||||
* protected members are deliberately absent; original class/member JSDoc is
|
||||
* retained so the projection is the source-owned public contract.
|
||||
*/
|
||||
function sourcePublicApi(sourceRel: string, symbol: string): string | null {
|
||||
const abs = resolve(root, sourceRel)
|
||||
const text = readFileSync(abs, 'utf8')
|
||||
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, /* setParentNodes */ true)
|
||||
for (const stmt of sf.statements) {
|
||||
if (!ts.isClassDeclaration(stmt) || stmt.name?.text !== symbol) continue
|
||||
const classDoc = sourceJSDoc(text, stmt)
|
||||
const abstract = stmt.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AbstractKeyword) ? 'abstract ' : ''
|
||||
const typeParameters = stmt.typeParameters?.map(parameter => parameter.getText(sf)).join(', ')
|
||||
const heritage = stmt.heritageClauses?.map(clause => clause.getText(sf)).join(' ')
|
||||
const header = `declare ${abstract}class ${symbol}${typeParameters ? `<${typeParameters}>` : ''}${heritage ? ` ${heritage}` : ''} {`
|
||||
const members = stmt.members
|
||||
.filter(isPublicMember)
|
||||
.map((member) => {
|
||||
const jsDoc = sourceJSDoc(text, member)
|
||||
const declaration = bodylessMember(text, sf, member)
|
||||
return jsDoc === '' ? declaration : `${jsDoc}\n${declaration}`
|
||||
})
|
||||
const declaration = [header, ...members.map(member => member.split('\n').map(line => ` ${line}`).join('\n')), '}'].join('\n')
|
||||
return classDoc === '' ? declaration : `${classDoc}\n${declaration}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const manifestRaw = readFileSync(resolve(root, 'scripts/type-equiv.manifest.json'), 'utf8')
|
||||
const manifest = JSON.parse(manifestRaw) as { entries: ManifestEntry[] }
|
||||
const entries = manifest.entries
|
||||
|
||||
// Key a block/entry by doc + symbol (a symbol may be documented in more than one
|
||||
// doc, but at most once per doc).
|
||||
const keyOf = (x: { doc: string; symbol: string }): string => `${x.doc}::${x.symbol}`
|
||||
// Key a block/entry by doc + symbol + projection. A symbol may be documented in
|
||||
// more than one doc, and a doc may carry both complete and projected forms.
|
||||
const keyOf = (x: { doc: string; symbol: string; projection?: 'public-api' }): string =>
|
||||
`${x.doc}::${x.symbol}::${x.projection ?? 'declaration'}`
|
||||
|
||||
// Collect every type-equiv block across ALL docs in scope — not only the docs
|
||||
// the manifest names — so a block in an unmanifested doc is found and reported
|
||||
@@ -133,7 +233,7 @@ for (const d of [...new Set(entries.map(e => e.doc))]) {
|
||||
else if (!docSet.has(d)) errors.push(`manifest references ${d}, which is outside the scanned markdown scope (${MARKDOWN_GLOBS.join(', ')})`)
|
||||
}
|
||||
|
||||
// Duplicate-block guard: the same symbol twice in one doc is ambiguous.
|
||||
// Duplicate-block guard: the same projected symbol twice in one doc is ambiguous.
|
||||
const blockByKey = new Map<string, EquivBlock>()
|
||||
for (const b of blocks) {
|
||||
const k = keyOf(b)
|
||||
@@ -173,16 +273,25 @@ let verified = 0
|
||||
for (const e of entries) {
|
||||
const b = blockByKey.get(keyOf(e))
|
||||
if (!b) continue // already reported as an orphan entry
|
||||
const decl = sourceDeclaration(e.source, e.symbol)
|
||||
const decl = e.projection === 'public-api'
|
||||
? sourcePublicApi(e.source, e.symbol)
|
||||
: sourceDeclaration(e.source, e.symbol)
|
||||
if (decl === null) {
|
||||
errors.push(`symbol ${e.symbol} not found in ${e.source} (manifest entry for ${e.doc})`)
|
||||
continue
|
||||
}
|
||||
if (normalize(decl) !== normalize(stripExport(b.code))) {
|
||||
const doc = stripExport(b.code)
|
||||
const sourceStructure = normalizeStructure(decl)
|
||||
const docStructure = normalizeStructure(doc)
|
||||
const sourceJSDoc = normalizeJSDoc(decl)
|
||||
const docJSDoc = normalizeJSDoc(doc)
|
||||
if (sourceStructure !== docStructure || JSON.stringify(sourceJSDoc) !== JSON.stringify(docJSDoc)) {
|
||||
errors.push(
|
||||
`DRIFT: ${e.doc}:${b.line} — type-equiv block for ${e.symbol} does not match ${e.source}.\n`
|
||||
+ ` source: ${normalize(decl)}\n`
|
||||
+ ` doc: ${normalize(stripExport(b.code))}`,
|
||||
+ ` source structure: ${sourceStructure}\n`
|
||||
+ ` doc structure: ${docStructure}\n`
|
||||
+ ` source JSDoc: ${JSON.stringify(sourceJSDoc)}\n`
|
||||
+ ` doc JSDoc: ${JSON.stringify(docJSDoc)}`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
@@ -190,7 +299,7 @@ for (const e of entries) {
|
||||
}
|
||||
|
||||
if (errors.length === 0) {
|
||||
console.log(`verify-type-equiv: ${verified} type-equiv block(s) match source (1:1 with manifest).`)
|
||||
console.log(`verify-type-equiv: ${verified} type-equiv block(s) match source structure and JSDoc (1:1 with manifest).`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user