Merge origin/master into feat/website-docs
Conflict resolution notes: - package.json/run-gates: both sides' new doc-sync gates kept (master's scoped-events/readme gates + this branch's website-api/website-yaml); js-yaml devDeps deduped (master added them independently). - pnpm-workspace/knip: website AND python/sdk-runtime entries kept. - doc-typecheck/verify-type-equiv: master's condensed headers kept, website glob retained in both scan scopes. - vendor/cordis/src/fiber.ts: master's lifecycle-hardening code taken; this branch's richer FiberState JSDoc reapplied on top. vendor/README.md logs both local modifications (hardening = 6, JSDoc enrichment = 7). - pnpm-lock: regenerated from master's side (pnpm install). Post-merge sync the gates forced (the system working as designed): - verify-website-yaml caught 4 stale plugin names from master's package reorg (dsh-stdio-agent -> dsh-stdio-demo, dsh-acp-agent -> dsh-acp-demo); 8 references fixed across guide/ and develop/. - gen-website-api picked up master's 6 new services automatically (ctx.approval/permission/sandbox/sessionQuery/skills/tasks -> 6 new pages + sidebar); api/index.md hub updated to list them. - AGENTS.md budget ceiling 1370 -> 1400: the website rows (layout line + two command lines) and master's own growth collided with the old ceiling; all three website rows are load-bearing (new top-level dir, new CI command).
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* 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.
|
||||
* The staged closure is symlink-free, and whole-tree assets cover Cordis's
|
||||
* runtime imports that pkg cannot discover statically.
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, statSync } from 'node:fs'
|
||||
import { copyFile, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { basename, join, resolve, sep } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
/** The closure manifest whose dependencies define the executable. */
|
||||
const DEPLOY_ROOT_PACKAGE = 'dsh-jsonrpc-agent-pkg'
|
||||
/** The app entry inside the deployed closure. */
|
||||
const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js'
|
||||
const OUTPUT_BASENAME = 'dsh-jsonrpc-agent-pkg'
|
||||
/** Default Node major; SEA mode requires at least Node 22. */
|
||||
const DEFAULT_NODE_RANGE = 'node24'
|
||||
/** Pinned for reproducible builds. */
|
||||
const PKG_SPEC = '@yao-pkg/pkg@6.21.0'
|
||||
const OUT_DIR = 'dist-exe'
|
||||
/** Python package destination; created when absent. */
|
||||
const PYTHON_RUNTIME_DIR = 'python/sdk-runtime/src/deepseek_harness_runtime/runtime'
|
||||
/** The deployed closure doubles as the node-mode carrier. */
|
||||
const PYTHON_NODE_SUBDIR = 'node'
|
||||
/** Documentation excluded from the generated runtime directory. */
|
||||
const DEPLOY_ONLY_DOCS = ['README.md', 'README.zh.md', 'README.i18n.yaml']
|
||||
|
||||
/**
|
||||
* Whole-tree assets cover Cordis's runtime bare-package imports, which pkg's
|
||||
* static analysis cannot see. Package manifests are explicit because bare-name
|
||||
* resolution depends on them.
|
||||
*/
|
||||
const ASSET_GLOBS = [
|
||||
'package.json',
|
||||
'node_modules/**/*.js',
|
||||
'node_modules/**/*.cjs',
|
||||
'node_modules/**/*.mjs',
|
||||
'node_modules/**/package.json',
|
||||
'node_modules/**/*.json',
|
||||
'node_modules/**/*.node',
|
||||
'node_modules/**/*.wasm',
|
||||
]
|
||||
|
||||
const PLATFORMS = ['linux', 'macos'] as const
|
||||
const ARCHES = ['x64', 'arm64'] as const
|
||||
type Platform = (typeof PLATFORMS)[number]
|
||||
type Arch = (typeof ARCHES)[number]
|
||||
|
||||
function isPlatform(value: string): value is Platform {
|
||||
return (PLATFORMS as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
function isArch(value: string): value is Arch {
|
||||
return (ARCHES as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* A parsed pkg target triple, constructed from `--targets` or the host.
|
||||
*/
|
||||
class Target {
|
||||
private constructor(
|
||||
/** pkg Node range (`node<major>`). */
|
||||
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).
|
||||
*/
|
||||
readonly platform: Platform,
|
||||
/** pkg CPU tag. */
|
||||
readonly arch: Arch,
|
||||
) {}
|
||||
|
||||
/** The pkg `--targets` spec string `<nodeRange>-<platform>-<arch>`. */
|
||||
get spec(): string {
|
||||
return `${this.nodeRange}-${this.platform}-${this.arch}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one target spec, rejecting malformed triples and unsupported platform or architecture.
|
||||
* @param spec - the raw triple, e.g. `node24-linux-x64`.
|
||||
* @returns the parsed target.
|
||||
*/
|
||||
static parse(spec: string): Target {
|
||||
const parts = spec.split('-')
|
||||
const [nodeRange, platform, arch] = parts
|
||||
if (parts.length !== 3 || nodeRange === undefined || platform === undefined || arch === undefined) {
|
||||
throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)} must be <nodeRange>-<platform>-<arch>, e.g. node24-linux-x64.`)
|
||||
}
|
||||
if (!/^node\d+$/.test(nodeRange)) {
|
||||
throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: node range must look like node24, got ${JSON.stringify(nodeRange)}.`)
|
||||
}
|
||||
if (!isPlatform(platform)) {
|
||||
throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: platform must be one of ${PLATFORMS.join(', ')}, got ${JSON.stringify(platform)}.`)
|
||||
}
|
||||
if (!isArch(arch)) {
|
||||
throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: arch must be one of ${ARCHES.join(', ')}, got ${JSON.stringify(arch)}.`)
|
||||
}
|
||||
return new Target(nodeRange, platform, arch)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the host-platform default on Node 24.
|
||||
* @returns the host target; throws on an unsupported host platform or arch.
|
||||
*/
|
||||
static host(): Target {
|
||||
const platform = process.platform === 'darwin' ? 'macos' : process.platform === 'linux' ? 'linux' : undefined
|
||||
if (platform === undefined) {
|
||||
throw new Error(`build-exe-for-python-sdk: unsupported host platform ${process.platform}; pass --targets explicitly.`)
|
||||
}
|
||||
const arch = process.arch === 'x64' || process.arch === 'arm64' ? process.arch : undefined
|
||||
if (arch === undefined) {
|
||||
throw new Error(`build-exe-for-python-sdk: unsupported host arch ${process.arch}; pass --targets explicitly.`)
|
||||
}
|
||||
return new Target(DEFAULT_NODE_RANGE, platform, arch)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validated CLI configuration; construction owns help and parse-error exits.
|
||||
*/
|
||||
class BuildCli {
|
||||
private constructor(
|
||||
/** Build targets; defaults to the host platform only. */
|
||||
readonly targets: readonly Target[],
|
||||
/** Skip step 1 (`pnpm run build`); lib/ artifacts must already exist. */
|
||||
readonly skipBuild: boolean,
|
||||
/** Print every command and config patch instead of executing. */
|
||||
readonly dryRun: boolean,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Parse argv. Help exits 0; malformed flags exit 1; invalid or colliding
|
||||
* targets throw.
|
||||
* @param argv - the raw arguments (`process.argv.slice(2)`).
|
||||
* @returns the parsed, validated configuration.
|
||||
*/
|
||||
static parse(argv: string[]): BuildCli {
|
||||
let values: ReturnType<typeof BuildCli.parseRaw>
|
||||
try {
|
||||
values = BuildCli.parseRaw(argv)
|
||||
} catch (error) {
|
||||
console.error(`build-exe-for-python-sdk: ${error instanceof Error ? error.message : String(error)}\n`)
|
||||
console.error(BuildCli.usage())
|
||||
process.exit(1)
|
||||
}
|
||||
if (values.help) {
|
||||
console.log(BuildCli.usage())
|
||||
process.exit(0)
|
||||
}
|
||||
const targets = values.targets === undefined
|
||||
? [Target.host()]
|
||||
: values.targets.split(',').map(part => part.trim()).filter(part => part !== '').map(spec => Target.parse(spec))
|
||||
if (targets.length === 0) throw new Error('build-exe-for-python-sdk: --targets is empty.')
|
||||
const seen = new Set<string>()
|
||||
for (const target of targets) {
|
||||
const key = `${target.platform}-${target.arch}`
|
||||
if (seen.has(key)) {
|
||||
throw new Error(`build-exe-for-python-sdk: duplicate platform-arch ${key} in --targets; canonical product names would collide.`)
|
||||
}
|
||||
seen.add(key)
|
||||
}
|
||||
return new BuildCli(targets, values['skip-build'], values['dry-run'])
|
||||
}
|
||||
|
||||
private static parseRaw(argv: string[]) {
|
||||
return parseArgs({
|
||||
args: argv,
|
||||
options: {
|
||||
'targets': { type: 'string' },
|
||||
'skip-build': { type: 'boolean', default: false },
|
||||
'dry-run': { type: 'boolean', default: false },
|
||||
'help': { type: 'boolean', default: false },
|
||||
},
|
||||
}).values
|
||||
}
|
||||
|
||||
private static usage(): string {
|
||||
return [
|
||||
'Usage: pnpm exec tsx scripts/build-exe-for-python-sdk.ts [flags]',
|
||||
'',
|
||||
' --targets=<t1,t2,...> pkg targets, e.g. node24-linux-x64,node24-linux-arm64,node24-macos-arm64.',
|
||||
' Default: the host platform only (on node24).',
|
||||
' --skip-build skip `pnpm run build` (lib/ artifacts must already exist).',
|
||||
' --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.`,
|
||||
`Stages the node carrier in ${PYTHON_RUNTIME_DIR}/${PYTHON_NODE_SUBDIR} and writes executables to ${OUT_DIR}/.`,
|
||||
].join('\n')
|
||||
}
|
||||
}
|
||||
|
||||
function pnpmBin(): string {
|
||||
return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a command for logs and errors, quoting arguments with spaces.
|
||||
* @param command - the executable.
|
||||
* @param args - its arguments.
|
||||
* @returns the printable command line.
|
||||
*/
|
||||
function formatCommand(command: string, args: string[]): string {
|
||||
return [command, ...args].map(part => (part.includes(' ') ? JSON.stringify(part) : part)).join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Sequential build pipeline. Subprocesses inherit stdio and errors include
|
||||
* the command; dry runs print commands and filesystem changes.
|
||||
*/
|
||||
class SingleExeBuild {
|
||||
/**
|
||||
* The cleared deploy target, pkg input, and Python node-mode carrier. The
|
||||
* checked-in default `cordis.yml` remains in its parent directory.
|
||||
*/
|
||||
readonly staging = resolve(root, PYTHON_RUNTIME_DIR, PYTHON_NODE_SUBDIR)
|
||||
private readonly outDir = resolve(root, OUT_DIR)
|
||||
|
||||
constructor(private readonly cli: BuildCli) {}
|
||||
|
||||
/** Verify the closure before compiling or packaging. */
|
||||
async verifyClosure(): Promise<void> {
|
||||
await this.run('runtime dependency closure', pnpmBin(), ['run', 'verify-runtime-closure'])
|
||||
}
|
||||
|
||||
/** Build all package artifacts unless `--skip-build` was passed. */
|
||||
async build(): Promise<void> {
|
||||
if (this.cli.skipBuild) {
|
||||
console.log('build-exe-for-python-sdk: skipping pnpm run build (--skip-build)')
|
||||
return
|
||||
}
|
||||
await this.run('build', pnpmBin(), ['run', 'build'])
|
||||
}
|
||||
|
||||
/** Clear and deploy the runtime closure into the node carrier. */
|
||||
async deployStaging(): Promise<void> {
|
||||
if (this.staging === root || root.startsWith(this.staging + sep)) {
|
||||
throw new Error(`build-exe-for-python-sdk: refusing to clear staging dir ${this.staging}: it contains the repo root.`)
|
||||
}
|
||||
if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${this.staging}`)
|
||||
else await rm(this.staging, { recursive: true, force: true })
|
||||
await this.run('deploy', pnpmBin(), [
|
||||
'--filter',
|
||||
DEPLOY_ROOT_PACKAGE,
|
||||
'deploy',
|
||||
'--legacy',
|
||||
'--prod',
|
||||
'--config.node-linker=hoisted',
|
||||
'--config.auto-install-peers=false',
|
||||
'--config.link-workspace-packages=true',
|
||||
this.staging,
|
||||
])
|
||||
if (this.cli.dryRun) {
|
||||
for (const name of DEPLOY_ONLY_DOCS) console.log(`build-exe-for-python-sdk: [dry-run] rm -f ${join(this.staging, name)}`)
|
||||
} else {
|
||||
await Promise.all(DEPLOY_ONLY_DOCS.map(name => rm(join(this.staging, name), { force: true })))
|
||||
}
|
||||
}
|
||||
|
||||
/** Add the executable entry and pkg assets to the staged manifest. */
|
||||
async injectPkgConfig(): Promise<void> {
|
||||
const patch = { bin: ENTRY_BIN, pkg: { assets: ASSET_GLOBS } }
|
||||
const manifestPath = join(this.staging, 'package.json')
|
||||
if (this.cli.dryRun) {
|
||||
console.log(`build-exe-for-python-sdk: [dry-run] patch ${manifestPath} with ${JSON.stringify(patch)}`)
|
||||
return
|
||||
}
|
||||
if (!existsSync(manifestPath)) {
|
||||
throw new Error(`build-exe-for-python-sdk: ${manifestPath} missing — pnpm deploy did not produce a staged package.`)
|
||||
}
|
||||
if (!existsSync(join(this.staging, ENTRY_BIN))) {
|
||||
throw new Error(`build-exe-for-python-sdk: ${join(this.staging, ENTRY_BIN)} missing — run without --skip-build so lib/ artifacts exist.`)
|
||||
}
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Record<string, unknown>
|
||||
await writeFile(manifestPath, `${JSON.stringify({ ...manifest, ...patch }, null, 2)}\n`)
|
||||
console.log(`build-exe-for-python-sdk: injected pkg config into ${manifestPath}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Package one target; SEA mode accepts one target per invocation.
|
||||
* @param target - the pkg target triple to build.
|
||||
* @returns the canonical product path `<out>/dsh-jsonrpc-agent-pkg-<platform>-<arch>`.
|
||||
*/
|
||||
async pack(target: Target): Promise<string> {
|
||||
const product = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`)
|
||||
if (!this.cli.dryRun) mkdirSync(this.outDir, { recursive: true })
|
||||
await this.run(`pkg ${target.spec}`, pnpmBin(), [
|
||||
'dlx',
|
||||
PKG_SPEC,
|
||||
this.staging,
|
||||
'--sea',
|
||||
'--targets',
|
||||
target.spec,
|
||||
'--output',
|
||||
product,
|
||||
])
|
||||
if (!this.cli.dryRun && !existsSync(product)) {
|
||||
throw new Error(`build-exe-for-python-sdk: product ${product} is missing after the pkg run; inspect ${this.outDir}.`)
|
||||
}
|
||||
return product
|
||||
}
|
||||
|
||||
/**
|
||||
* Print each product path and, outside dry-run mode, its size.
|
||||
* @param products - the product paths returned by {@link pack}.
|
||||
*/
|
||||
printProducts(products: string[]): void {
|
||||
console.log(this.cli.dryRun ? 'build-exe-for-python-sdk: [dry-run] would produce:' : 'build-exe-for-python-sdk: products:')
|
||||
for (const product of products) {
|
||||
if (this.cli.dryRun) {
|
||||
console.log(` ${product}`)
|
||||
continue
|
||||
}
|
||||
const megabytes = statSync(product).size / (1024 * 1024)
|
||||
console.log(` ${product} (${megabytes.toFixed(1)} MB)`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy each executable into the Python runtime package. The deployed node
|
||||
* carrier is already in place, and `dist-exe/` retains upload copies.
|
||||
* @param products - the product paths returned by {@link pack}.
|
||||
*/
|
||||
async syncToPythonRuntime(products: string[]): Promise<void> {
|
||||
const destDir = resolve(root, PYTHON_RUNTIME_DIR)
|
||||
if (this.cli.dryRun) {
|
||||
for (const product of products) {
|
||||
console.log(`build-exe-for-python-sdk: [dry-run] cp ${product} ${join(destDir, basename(product))}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
mkdirSync(destDir, { recursive: true })
|
||||
for (const product of products) {
|
||||
const destination = join(destDir, basename(product))
|
||||
await copyFile(product, destination)
|
||||
console.log(`build-exe-for-python-sdk: synced ${destination}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one subprocess with inherited stdio. Spawn and non-zero-exit errors
|
||||
* include the command; dry runs only print it.
|
||||
* @param label - the step name used in logs and error messages.
|
||||
* @param command - the executable.
|
||||
* @param args - its arguments.
|
||||
*/
|
||||
private async run(label: string, command: string, args: string[]): Promise<void> {
|
||||
const printable = formatCommand(command, args)
|
||||
if (this.cli.dryRun) {
|
||||
console.log(`build-exe-for-python-sdk: [dry-run] ${printable}`)
|
||||
return
|
||||
}
|
||||
console.log(`build-exe-for-python-sdk: ${label}: ${printable}`)
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
const child = spawn(command, args, { cwd: root, stdio: 'inherit' })
|
||||
child.once('error', (error) => {
|
||||
reject(new Error(`build-exe-for-python-sdk: ${label} failed to spawn: ${error.message} (${printable})`))
|
||||
})
|
||||
child.once('exit', (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolvePromise()
|
||||
return
|
||||
}
|
||||
const cause = code === null ? `signal ${signal ?? 'unknown'}` : `exit code ${code}`
|
||||
reject(new Error(`build-exe-for-python-sdk: ${label} failed (${cause}): ${printable}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const cli = BuildCli.parse(process.argv.slice(2))
|
||||
const pipeline = new SingleExeBuild(cli)
|
||||
console.log(`build-exe-for-python-sdk: targets: ${cli.targets.map(target => target.spec).join(', ')}`)
|
||||
console.log(`build-exe-for-python-sdk: staging: ${pipeline.staging}`)
|
||||
await pipeline.verifyClosure()
|
||||
await pipeline.build()
|
||||
await pipeline.deployStaging()
|
||||
await pipeline.injectPkgConfig()
|
||||
const products: string[] = []
|
||||
for (const target of cli.targets) products.push(await pipeline.pack(target))
|
||||
pipeline.printProducts(products)
|
||||
await pipeline.syncToPythonRuntime(products)
|
||||
}
|
||||
|
||||
await main()
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stage and build one Python wheel at the repository version."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import email
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PLATFORMS = {
|
||||
"linux-x64": ("manylinux_2_28_x86_64", "dsh-jsonrpc-agent-pkg-linux-x64"),
|
||||
"linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"),
|
||||
"macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--package", choices=("sdk", "runtime"), required=True)
|
||||
parser.add_argument(
|
||||
"--tag",
|
||||
help="optional python-vX.Y.Z release tag; it must match package.json",
|
||||
)
|
||||
parser.add_argument("--output-dir", type=Path, required=True)
|
||||
parser.add_argument("--platform", choices=tuple(PLATFORMS))
|
||||
parser.add_argument("--runtime-exe", type=Path)
|
||||
args = parser.parse_args()
|
||||
version = repository_version()
|
||||
validate_release_tag(args.tag, version)
|
||||
if args.package == "runtime" and (args.platform is None or args.runtime_exe is None):
|
||||
parser.error("runtime builds require --platform and --runtime-exe")
|
||||
if args.package == "sdk" and (args.platform is not None or args.runtime_exe is not None):
|
||||
parser.error("SDK builds do not accept --platform or --runtime-exe")
|
||||
|
||||
output_dir = args.output_dir.resolve()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix="dsh-python-release-") as temporary:
|
||||
staging = Path(temporary) / args.package
|
||||
if args.package == "sdk":
|
||||
stage_sdk(staging, version)
|
||||
environment = None
|
||||
expected = output_dir / f"deepseek_harness-{version}-py3-none-any.whl"
|
||||
else:
|
||||
platform_tag, executable_name = PLATFORMS[args.platform]
|
||||
stage_runtime(staging, version, args.runtime_exe.resolve(), executable_name)
|
||||
environment = {"DSH_RUNTIME_PLATFORM_TAG": platform_tag}
|
||||
expected = output_dir / f"deepseek_harness_runtime_bin-{version}-py3-none-{platform_tag}.whl"
|
||||
command = ["uv", "build", "--wheel", "--out-dir", str(output_dir), str(staging)]
|
||||
subprocess.run(command, cwd=ROOT, env=None if environment is None else {**os.environ, **environment}, check=True)
|
||||
if not expected.is_file():
|
||||
raise RuntimeError(f"build did not produce expected wheel: {expected}")
|
||||
verify_wheel(expected, args.package, version, None if args.platform is None else PLATFORMS[args.platform])
|
||||
print(expected)
|
||||
|
||||
|
||||
def repository_version(root: Path = ROOT) -> str:
|
||||
package_json = root / "package.json"
|
||||
try:
|
||||
payload = json.loads(package_json.read_text())
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise ValueError(f"could not read repository version from {package_json}") from error
|
||||
version = payload.get("version") if isinstance(payload, dict) else None
|
||||
if not isinstance(version, str) or re.fullmatch(r"\d+\.\d+\.\d+", version) is None:
|
||||
raise ValueError(
|
||||
f"{package_json} version must be stable X.Y.Z, got {version!r}"
|
||||
)
|
||||
return version
|
||||
|
||||
|
||||
def validate_release_tag(tag: str | None, version: str) -> None:
|
||||
if tag is None:
|
||||
return
|
||||
expected = f"python-v{version}"
|
||||
if tag != expected:
|
||||
raise ValueError(
|
||||
f"release tag must match repository version: expected {expected!r}, got {tag!r}"
|
||||
)
|
||||
|
||||
|
||||
def copy_package(source: Path, destination: Path) -> None:
|
||||
shutil.copytree(
|
||||
source,
|
||||
destination,
|
||||
ignore=shutil.ignore_patterns(
|
||||
".venv",
|
||||
".pytest_cache",
|
||||
"__pycache__",
|
||||
"*.pyc",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"dsh-jsonrpc-agent-pkg-*",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def rewrite_version(pyproject: Path, version: str) -> None:
|
||||
text, count = re.subn(
|
||||
r'^version = "[^"]+"$',
|
||||
f'version = "{version}"',
|
||||
pyproject.read_text(),
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
if count != 1:
|
||||
raise RuntimeError(f"could not rewrite version in {pyproject}")
|
||||
pyproject.write_text(text)
|
||||
|
||||
|
||||
def stage_sdk(destination: Path, version: str) -> None:
|
||||
copy_package(ROOT / "python" / "sdk", destination)
|
||||
pyproject = destination / "pyproject.toml"
|
||||
rewrite_version(pyproject, version)
|
||||
text, count = re.subn(
|
||||
r'"deepseek-harness-runtime-bin==[^"]+"',
|
||||
f'"deepseek-harness-runtime-bin=={version}"',
|
||||
pyproject.read_text(),
|
||||
count=1,
|
||||
)
|
||||
if count != 1:
|
||||
raise RuntimeError("SDK must contain exactly one runtime dependency pin")
|
||||
pyproject.write_text(text)
|
||||
|
||||
|
||||
def stage_runtime(destination: Path, version: str, executable: Path, executable_name: str) -> None:
|
||||
if not executable.is_file():
|
||||
raise FileNotFoundError(f"runtime executable does not exist: {executable}")
|
||||
if executable.stat().st_mode & stat.S_IXUSR == 0:
|
||||
raise PermissionError(f"runtime executable is not executable: {executable}")
|
||||
copy_package(ROOT / "python" / "sdk-runtime", destination)
|
||||
rewrite_version(destination / "pyproject.toml", version)
|
||||
runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime"
|
||||
runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||
destination_executable = runtime_dir / executable_name
|
||||
shutil.copyfile(executable, destination_executable)
|
||||
destination_executable.chmod(executable.stat().st_mode & 0o777)
|
||||
|
||||
|
||||
def verify_wheel(
|
||||
wheel: Path,
|
||||
package: str,
|
||||
version: str,
|
||||
platform: tuple[str, str] | None,
|
||||
) -> None:
|
||||
expected_tag = "py3-none-any" if platform is None else f"py3-none-{platform[0]}"
|
||||
with zipfile.ZipFile(wheel) as archive:
|
||||
wheel_metadata_path = next(name for name in archive.namelist() if name.endswith(".dist-info/WHEEL"))
|
||||
metadata_path = next(name for name in archive.namelist() if name.endswith(".dist-info/METADATA"))
|
||||
wheel_metadata = email.message_from_bytes(archive.read(wheel_metadata_path))
|
||||
metadata = email.message_from_bytes(archive.read(metadata_path))
|
||||
if wheel_metadata.get_all("Tag") != [expected_tag]:
|
||||
raise RuntimeError(f"{wheel} has wrong WHEEL tags: {wheel_metadata.get_all('Tag')}")
|
||||
if metadata.get("Version") != version:
|
||||
raise RuntimeError(f"{wheel} has version {metadata.get('Version')}, expected {version}")
|
||||
executables = [name for name in archive.namelist() if "/runtime/dsh-jsonrpc-agent-pkg-" in name]
|
||||
if package == "runtime":
|
||||
assert platform is not None
|
||||
if len(executables) != 1 or not executables[0].endswith(f"/runtime/{platform[1]}"):
|
||||
raise RuntimeError(f"{wheel} must contain exactly {platform[1]}, found {executables}")
|
||||
mode = archive.getinfo(executables[0]).external_attr >> 16
|
||||
if mode & stat.S_IXUSR == 0:
|
||||
raise RuntimeError(f"{wheel} runtime executable lost its executable bit")
|
||||
elif executables:
|
||||
raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {executables}")
|
||||
if package == "sdk":
|
||||
requirements = metadata.get_all("Requires-Dist") or []
|
||||
expected_requirement = f"deepseek-harness-runtime-bin=={version}"
|
||||
if expected_requirement not in requirements:
|
||||
raise RuntimeError(f"{wheel} does not pin {expected_requirement}; found {requirements}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -61,6 +61,9 @@ function readJson(path: string): PackageManifest {
|
||||
return JSON.parse(readFileSync(path, 'utf8')) as PackageManifest
|
||||
}
|
||||
|
||||
const rootManifest = readJson(join(root, 'package.json'))
|
||||
const repositoryVersion = rootManifest.version
|
||||
|
||||
/** Repo-relative dirs holding a package.json, walked to the configured depth. */
|
||||
function packageDirs(base: string, depth: number): string[] {
|
||||
if (depth === 1) {
|
||||
@@ -78,7 +81,7 @@ function packageDirs(base: string, depth: number): string[] {
|
||||
|
||||
function workspaceManifests(): WorkspaceManifest[] {
|
||||
const manifests: WorkspaceManifest[] = [
|
||||
{ dir: '.', manifest: readJson(join(root, 'package.json')) },
|
||||
{ dir: '.', manifest: rootManifest },
|
||||
]
|
||||
|
||||
for (const { dir: base, depth } of workspaceGlobs) {
|
||||
@@ -107,17 +110,37 @@ const dshBinPackageFiles = [
|
||||
|
||||
const dshWorkerPackageFiles = [
|
||||
'lib/index.js',
|
||||
'lib/worker.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[] {
|
||||
const extras = manifest.name ? packageFileExtras[manifest.name] ?? [] : []
|
||||
if (extras.length > 0) {
|
||||
return [
|
||||
'lib/index.js',
|
||||
...manifest.bin ? ['lib/bin.js'] : [],
|
||||
...extras,
|
||||
'lib/types/**/*.d.ts',
|
||||
'lib/types/**/*.d.ts.map',
|
||||
'src',
|
||||
]
|
||||
}
|
||||
if (manifest.bin) return dshBinPackageFiles
|
||||
// A declared "./worker" subpath export sanctions the one extra runtime
|
||||
// bundle a worker-thread entry needs (and NodeNext/publint then validate
|
||||
@@ -147,8 +170,8 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
|
||||
if (peer && dev && peer !== dev) {
|
||||
errors.push(`${label}: cordis peer (${peer}) and dev (${dev}) ranges must match`)
|
||||
}
|
||||
if (manifest.version !== '0.0.1') {
|
||||
errors.push(`${label}: package.json must set "version": "0.0.1"`)
|
||||
if (manifest.version !== repositoryVersion) {
|
||||
errors.push(`${label}: package.json version must match root version ${repositoryVersion ?? '(missing)'}`)
|
||||
}
|
||||
if (manifest.type !== 'module') {
|
||||
errors.push(`${label}: package.json must set "type": "module"`)
|
||||
@@ -175,13 +198,8 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce the packages/ hierarchy SHAPE: every package lives at exactly
|
||||
* `packages/<group>/<pkg>`. A group dir is a pure container — it holds packages,
|
||||
* never sources of its own — so it must NOT carry a package.json, and a package
|
||||
* must NOT sit directly at the `packages/` root (the old flat layout) nor nest a
|
||||
* level deeper. The group NAMES are open on purpose: a new group may be added
|
||||
* without touching this gate, but the depth-2 shape is fixed. This is what keeps
|
||||
* a stray flat package or an over-nested one from regressing the hierarchy.
|
||||
* Enforce `packages/<group>/<pkg>`: groups are open-named containers without a
|
||||
* package.json, and packages may be neither flat nor more deeply nested.
|
||||
*/
|
||||
function checkHierarchyShape(): string[] {
|
||||
const errors: string[] = []
|
||||
@@ -205,7 +223,16 @@ function checkHierarchyShape(): string[] {
|
||||
return errors
|
||||
}
|
||||
|
||||
const errors = [...workspaceManifests().flatMap(checkWorkspace), ...checkHierarchyShape()]
|
||||
function checkRepositoryVersion(): string[] {
|
||||
if (repositoryVersion && /^\d+\.\d+\.\d+$/.test(repositoryVersion)) return []
|
||||
return ['package.json: version must be stable X.Y.Z']
|
||||
}
|
||||
|
||||
const errors = [
|
||||
...checkRepositoryVersion(),
|
||||
...workspaceManifests().flatMap(checkWorkspace),
|
||||
...checkHierarchyShape(),
|
||||
]
|
||||
if (errors.length > 0) {
|
||||
console.error(errors.join('\n'))
|
||||
process.exitCode = 1
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
/**
|
||||
* Boot the Code Mode demo under the UI named on the command line:
|
||||
* `pnpm run demo:code-mode [repl|acp]`, default `repl`. Code Mode is the
|
||||
* point — the UI is just the surface it happens to wear: each UI boots its
|
||||
* base example through that example's `code-mode.cordis.yml` overlay
|
||||
* (include ./cordis.yml, flip `tools.mode` to `code`, insert the
|
||||
* worker-thread code runtime). Both need DEEPSEEK_API_KEY (repo-root .env
|
||||
* works). Anything else on the command line is a misconfiguration and
|
||||
* fails loud with usage.
|
||||
* Boot the REPL or ACP Code Mode overlay, defaulting to REPL. 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.
|
||||
*/
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
@@ -14,8 +9,8 @@ import { spawn } from 'node:child_process'
|
||||
// the overlay config (the stdio bin keeps --expose-internals for the cordis
|
||||
// Loader's HMR path).
|
||||
const UIS = new Map([
|
||||
['repl', ['--expose-internals', '--import', 'tsx', 'packages/ui/stdio-agent/src/bin.ts', 'examples/coding-agent/code-mode.cordis.yml']],
|
||||
['acp', ['--import', 'tsx', 'packages/ui/acp-agent/src/bin.ts', 'examples/acp-agent/code-mode.cordis.yml']],
|
||||
['repl', ['--expose-internals', '--import', 'tsx', 'packages/examples/stdio-demo/src/bin.ts', 'examples/coding-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'
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"AGENTS.md": 1802,
|
||||
"docs/AGENTS.md": 1315,
|
||||
"docs/architecture.md": 1642,
|
||||
"docs/cordis-primer.md": 550,
|
||||
"AGENTS.md": 1400,
|
||||
"docs/AGENTS.md": 1100,
|
||||
"docs/architecture.md": 1790,
|
||||
"docs/cordis-primer.md": 600,
|
||||
"docs/defensive-patterns.md": 550,
|
||||
"docs/testing.md": 800,
|
||||
"examples/AGENTS.md": 653,
|
||||
"packages/AGENTS.md": 450,
|
||||
"packages/README.md": 660
|
||||
"examples/AGENTS.md": 200,
|
||||
"packages/AGENTS.md": 290,
|
||||
"packages/README.md": 760
|
||||
}
|
||||
+16
-55
@@ -1,28 +1,7 @@
|
||||
/**
|
||||
* Doc-sync gate (doc-sync-enforcement RFC, part 1): typecheck the fenced `ts` code blocks in our
|
||||
* Markdown so documentation can't drift from the API it documents.
|
||||
*
|
||||
* Every ```ts block in README.md, docs/**, packages/* /README.md and the
|
||||
* website tutorial pages (website/zh-CN/**) is
|
||||
* extracted to a temp typecheck project and compiled against the workspace
|
||||
* sources through the same project-reference boundaries used by repo
|
||||
* typecheck. A block that is a deliberate sketch rather than compilable code
|
||||
* opts out with an explicit ` ```ts ignore-check ` info string — the opt-out
|
||||
* is visible in the source, and this script reports the ratio so the escape
|
||||
* hatch can't quietly become the norm. A third info string,
|
||||
* doc-typecheck.ts recognizes four more fence variants and skips all four (each
|
||||
* is a separately-checked category, not an unchecked sketch, so none counts in
|
||||
* the opt-out ratio): ` ```ts type-equiv ` is a verbatim source-type paste that
|
||||
* `scripts/verify-type-equiv.ts` drift-checks, ` ```ts cordis-catalog ` is a
|
||||
* generated event/service signature fragment in the cordis catalog (a bare
|
||||
* signature is not standalone-compilable; the catalog is generated and frozen by
|
||||
* `scripts/gen-cordis-catalog.ts` + its `--check` freshness gate),
|
||||
* ` ```ts persistence-catalog ` is a generated log-event payload fragment in the
|
||||
* persistence catalog (same reasoning, frozen by `scripts/gen-persistence-catalog.ts`),
|
||||
* and ` ```ts config-catalog ` is a generated verbatim config declaration in the
|
||||
* plugin config catalog (same reasoning, frozen by `scripts/gen-config-catalog.ts`).
|
||||
*
|
||||
* Run: `tsx scripts/doc-typecheck.ts`.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
@@ -33,28 +12,9 @@ import ts from 'typescript'
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
/**
|
||||
* How a fenced block participates in this gate:
|
||||
* - `check` (` ```ts `) — compiled.
|
||||
* - `ignore` (` ```ts ignore-check `) — a deliberate sketch; skipped, and
|
||||
* counted in the opt-out ratio so the escape hatch can't quietly take over.
|
||||
* - `type-equiv` (` ```ts type-equiv `) — a verbatim paste of a source type
|
||||
* definition, drift-checked by `scripts/verify-type-equiv.ts` against the
|
||||
* source symbol. Skipped HERE (it is not standalone-compilable — no imports)
|
||||
* and EXCLUDED from the opt-out ratio: it is a separate fully-checked
|
||||
* category, not an unchecked sketch.
|
||||
* - `cordis-catalog` (` ```ts cordis-catalog `) — a generated event/service
|
||||
* signature fragment in the cordis catalog. Skipped HERE for the same reason
|
||||
* (a bare signature fragment has no imports and does not stand alone) and
|
||||
* EXCLUDED from the opt-out ratio: the catalog is generated and frozen by
|
||||
* `scripts/gen-cordis-catalog.ts` + its `--check` freshness gate.
|
||||
* - `persistence-catalog` (` ```ts persistence-catalog `) — a generated
|
||||
* log-event payload fragment in the persistence catalog. Same treatment for
|
||||
* the same reason; frozen by `scripts/gen-persistence-catalog.ts` + its
|
||||
* `--check` freshness gate.
|
||||
* - `config-catalog` (` ```ts config-catalog `) — a generated verbatim config
|
||||
* declaration in the plugin config catalog (a lone declaration referencing
|
||||
* imported types does not stand alone). Same treatment for the same reason;
|
||||
* frozen by `scripts/gen-config-catalog.ts` + its `--check` freshness gate.
|
||||
* TypeScript-fence ownership. `check` compiles; `ignore` is an unchecked sketch
|
||||
* counted in the opt-out ratio; the catalog and type-equivalence variants are
|
||||
* excluded from that ratio because their owning gates verify them.
|
||||
*/
|
||||
type BlockKind = 'check' | 'ignore' | 'type-equiv' | 'cordis-catalog' | 'persistence-catalog' | 'config-catalog'
|
||||
|
||||
@@ -67,8 +27,7 @@ interface Block {
|
||||
code: string
|
||||
}
|
||||
|
||||
/** Extract every ts / ts ignore-check / ts type-equiv / ts cordis-catalog /
|
||||
* ts persistence-catalog / ts config-catalog block from one Markdown file. */
|
||||
/** Extract every recognized TypeScript fence from one Markdown file. */
|
||||
function extractBlocks(absPath: string): Block[] {
|
||||
const text = readFileSync(absPath, 'utf8')
|
||||
const lines = text.split('\n')
|
||||
@@ -88,7 +47,7 @@ function extractBlocks(absPath: string): Block[] {
|
||||
open = null
|
||||
return
|
||||
}
|
||||
// opening fence — only care about ts blocks
|
||||
// Ignore non-TypeScript fences.
|
||||
const info = (fence[2] ?? '').trim()
|
||||
const kind: BlockKind | null =
|
||||
info === 'ts' ? 'check'
|
||||
@@ -146,11 +105,8 @@ files.sort()
|
||||
const all = files.flatMap(extractBlocks)
|
||||
const checked = all.filter(b => b.kind === 'check')
|
||||
const ignored = all.filter(b => b.kind === 'ignore')
|
||||
// `type-equiv`, `cordis-catalog`, and `persistence-catalog` blocks are verified
|
||||
// elsewhere (verify-type-equiv.ts and each catalog generator's `--check`
|
||||
// freshness gate), not here: neither compiled nor counted toward the opt-out
|
||||
// ratio (each is a separate fully-checked category, not an unchecked sketch).
|
||||
// The ratio's denominator is therefore the compile-eligible blocks only.
|
||||
// Only compile-eligible fences belong in the opt-out ratio; every other skipped
|
||||
// kind has an independent verifier named in BlockKind's contract above.
|
||||
const ratioDenominator = checked.length + ignored.length
|
||||
|
||||
if (checked.length === 0) {
|
||||
@@ -169,7 +125,12 @@ try {
|
||||
})
|
||||
|
||||
try {
|
||||
execFileSync('node_modules/.bin/tsc', ['-b', join(tmp, 'tsconfig.json')], { cwd: root, stdio: 'pipe' })
|
||||
// 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() ?? ''}`
|
||||
|
||||
@@ -1,72 +1,14 @@
|
||||
/**
|
||||
* Generate (and verify) the plugin config catalog in docs/config-catalog.md.
|
||||
*
|
||||
* The page is the DEPLOYMENT-axis reference: for every harness package a
|
||||
* `cordis.yml` entry can load, the exact config surface its `apply` function or
|
||||
* service constructor receives — pasted VERBATIM from source (the `export
|
||||
* interface Config` declaration with its JSDoc), plus resolved links for every
|
||||
* type the declaration references. It complements the wiring-axis cordis
|
||||
* catalogs (events + services, what a plugin AUTHOR listens to and calls) the
|
||||
* same way the tool catalog complements them for the model-facing axis.
|
||||
*
|
||||
* The catalog is FULLY GENERATED from source — never hand-edit it. Like the
|
||||
* cordis catalog (and unlike the tool catalog, which must boot plugins), this
|
||||
* is a pure-AST pass: every config type is a static declaration and every
|
||||
* schemastery schema is a static `z.object`/`z.intersect` literal, so
|
||||
* generation cannot drift and a regenerate-and-diff freshness check (`--check`)
|
||||
* gates staleness. Because generation enumerates every package under
|
||||
* `packages/<group>/<pkg>`, a brand-new plugin cannot be silently
|
||||
* undocumented: it must classify as configurable, config-free, seam, or
|
||||
* library, and an unclassifiable entry hard-errors the generator.
|
||||
*
|
||||
* `tsx scripts/gen-config-catalog.ts` → write the catalog
|
||||
* `tsx scripts/gen-config-catalog.ts --check` → exit 1 if the committed
|
||||
* catalog is stale (CI /
|
||||
* pre-push gate)
|
||||
*
|
||||
* What the walk enforces (aggregated into one error, like the sibling
|
||||
* generators):
|
||||
*
|
||||
* - CLASSIFICATION is total. Every package entry resolves, mirroring the
|
||||
* cordis Loader's `unwrapExports` (`exports.default ?? exports`), to a
|
||||
* loadable plugin (default class / `apply` function), an abstract seam
|
||||
* class, or a plain library. Anything else is an error, not a skip.
|
||||
* - The CONFIG TYPE is the declared type of the plugin's second parameter
|
||||
* (`apply(ctx, config)` / `constructor(ctx, config)`) — the type cordis
|
||||
* actually passes — and it must resolve to a declaration inside the owning
|
||||
* package (entry file or a package-local relative import).
|
||||
* - Every property of a pasted declaration carries non-empty JSDoc prose: the
|
||||
* paste IS the documentation, so an undocumented field is a gate failure,
|
||||
* the same forcing function the events catalog applies via `@mode`.
|
||||
* - Every type NAME a pasted declaration references resolves: pasted
|
||||
* transitively when package-local, linked when it is another plugin's
|
||||
* config type / a core-data-structures entry / a workspace or external
|
||||
* import. An unresolvable name is an error, and so is a NAME COLLISION —
|
||||
* two distinct declarations, or a declaration and an import, sharing one
|
||||
* name across the closure (a verbatim fence has a single flat namespace) —
|
||||
* never a silent skip.
|
||||
* - The runtime schemastery schema (`Config` export or `static Config`),
|
||||
* when present, is walked statically — `z.object` keys, nested object/array
|
||||
* compositions as key PATHS (`agents[].id`), and `z.intersect` composition
|
||||
* across packages — and every schema-validated key path must be locatable
|
||||
* on the declared config type, resolving package-local and
|
||||
* workspace-imported types, re-export chains, intersections, utility
|
||||
* wrappers, and indexed access. The paste cannot hide a loader-accepted
|
||||
* field, top-level or nested. A path that crosses a type the walk cannot
|
||||
* enumerate (an external package's type) is skipped, never mis-reported,
|
||||
* and nested keys under dynamic-key shapes (`z.dict`) or union alternatives
|
||||
* contribute no paths. The reverse direction is deliberately NOT checked: a
|
||||
* declared field may be a runtime-only seam the schema excludes (e.g. the
|
||||
* ACP bridge's test-injected `stream`).
|
||||
*
|
||||
* Config fences use the ` ```ts config-catalog ` info string: doc-typecheck
|
||||
* recognizes it and skips compilation (a lone interface referencing imported
|
||||
* types is not standalone-compilable, like the ` ```ts cordis-catalog `
|
||||
* signature blocks).
|
||||
* Generate `docs/config-catalog.md` from package entry points, config types,
|
||||
* JSDoc, and static Schemastery schemas. Every package must classify, referenced
|
||||
* types must resolve without collisions, and every enumerable schema path must
|
||||
* exist on the declared config type. External and dynamic shapes stay unknown;
|
||||
* declared runtime-only fields need not appear in the schema. `--check` verifies
|
||||
* the committed artifact.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { dirname, resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { LINK_MAP } from './gen-cordis-catalog.ts'
|
||||
import { parseJsDoc, pointer, rawJsDoc } from './jsdoc.ts'
|
||||
@@ -353,7 +295,7 @@ function declForTypeName(world: World, ctx: FileCtx, name: string): { decl: Type
|
||||
entry = loadFile(resolve(world.scanRoot, entryRel), entryRel, world.cache)
|
||||
} catch {
|
||||
// A workspace package without a readable entry is reported by its own
|
||||
// classification pass; for a lookup it is merely out of reach.
|
||||
// classification pass; for a lookup it is out of reach.
|
||||
return 'unknown'
|
||||
}
|
||||
return findExportedTypeDecl(world, entry, imp.imported) ?? 'unknown'
|
||||
@@ -372,9 +314,8 @@ const PASSTHROUGH_WRAPPERS = new Set(['Partial', 'Required', 'Readonly', 'NonNul
|
||||
*/
|
||||
function lookupPath(world: World, ctx: FileCtx, node: ts.Node, steps: PathStep[], seen: Set<string>): PathLookup {
|
||||
if (steps.length === 0) return 'found'
|
||||
// Guard recursion at NAMED declarations only — the sole way a walk can loop
|
||||
// (a recursive interface/alias). Structural nodes must not be guarded: a
|
||||
// first child shares `.pos` with its parent, so a span-keyed guard there
|
||||
// Guard only named declarations, where recursive types can loop. Structural
|
||||
// children can share a source position with their parent, so guarding them
|
||||
// would mistake ordinary descent for a cycle.
|
||||
if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) {
|
||||
const key = `${ctx.abs}:${node.pos}:${steps.length}`
|
||||
@@ -537,6 +478,15 @@ function walkSchemaExpr(
|
||||
}
|
||||
return
|
||||
}
|
||||
// A union of objects (discriminated union config): collect keys from all
|
||||
// variants. Each variant is visited the same way as an intersect element.
|
||||
if (method === 'union' && call.arguments[0] && ts.isArrayLiteralExpression(call.arguments[0])) {
|
||||
for (const el of call.arguments[0].elements) {
|
||||
const part = unwrapExpr(el)
|
||||
if (ts.isCallExpression(part)) { visit(part); continue }
|
||||
}
|
||||
return
|
||||
}
|
||||
// A chained refinement (`z.object({…}).default(…)` etc.): the keys live on
|
||||
// the call the chain hangs off — keep unwrapping toward it.
|
||||
const base = unwrapExpr(call.expression.expression)
|
||||
@@ -631,13 +581,19 @@ export function collectConfigCatalog(scanRoot: string = root): CatalogEntry[] {
|
||||
// workspace-package imports while individual packages are still being walked.
|
||||
const pkgDirByName = new Map<string, string>()
|
||||
const manifests: { dir: string; pkg: string }[] = []
|
||||
for (const manifestRel of globSync('packages/*/*/package.json', { cwd: scanRoot }).sort()) {
|
||||
for (const manifestRel of globSync('packages/*/*/package.json', { cwd: scanRoot }).map(path => path.split(sep).join('/')).sort()) {
|
||||
const dir = manifestRel.slice(0, -'/package.json'.length)
|
||||
const pkg = (JSON.parse(readFileSync(resolve(scanRoot, manifestRel), 'utf8')) as { name?: string }).name
|
||||
const manifest = JSON.parse(readFileSync(resolve(scanRoot, manifestRel), 'utf8')) as { name?: string; os?: string[]; cpu?: string[] }
|
||||
const pkg = manifest.name
|
||||
if (!pkg) {
|
||||
violations.push(`${manifestRel} has no "name".`)
|
||||
continue
|
||||
}
|
||||
if (manifest.os !== undefined && manifest.cpu !== undefined) {
|
||||
// A per-platform native-binary package (npm os/cpu selection) ships no
|
||||
// JavaScript at all — nothing to classify, no Config to catalog.
|
||||
continue
|
||||
}
|
||||
pkgDirByName.set(pkg, dir)
|
||||
manifests.push({ dir, pkg })
|
||||
}
|
||||
@@ -769,10 +725,8 @@ export function collectConfigCatalog(scanRoot: string = root): CatalogEntry[] {
|
||||
}
|
||||
}
|
||||
|
||||
// Second phase: fold composed schemas' key paths in, then walk every
|
||||
// schema-validated path against the declared config type. Only a definite
|
||||
// miss is a violation — a path through a shape the walk cannot enumerate
|
||||
// stays silent rather than mis-reporting.
|
||||
// Fold composed schemas' key paths in, then check each path against the type.
|
||||
// Only a definite miss fails; shapes the walk cannot enumerate stay unknown.
|
||||
const byName = new Map(entries.map(e => [e.pkg, e]))
|
||||
for (const entry of entries) {
|
||||
if (entry.kind !== 'config' || entry.schemaKeys === null || entry.schemaKeys === undefined) continue
|
||||
|
||||
@@ -1,27 +1,8 @@
|
||||
/**
|
||||
* Generate (and verify) the runtime cordis API catalog the `cordis_inspect`
|
||||
* tool serves to the model: packages/cordis/tool-cordis/src/api-catalog.ts.
|
||||
*
|
||||
* The artifact is the machine-readable sibling of docs/cordis-catalog: it
|
||||
* reuses `collectServices` / `collectEvents` from `gen-cordis-catalog.ts` (the
|
||||
* same JSDoc-completeness-enforcing AST walk), so the API the model reads at
|
||||
* runtime and the API the docs render cannot diverge. Emitted as a typed
|
||||
* TypeScript data module (not JSON): it compiles under the package tsconfig,
|
||||
* passes lint and the export-JSDoc gate, and is trivially covered by import.
|
||||
*
|
||||
* The data is trimmed for a model-facing text surface: per service the
|
||||
* `ctx.<key>` name, the first sentence of the class doc, and the raw method
|
||||
* signatures; per event the name, `@mode`, signature, and first sentence of
|
||||
* doc; the SHAPES of every exported interface/type-alias the service
|
||||
* signatures reference (transitively — so a model can see that e.g. a
|
||||
* `BashRunResult.stdout` is `{ text, truncated }`, not a string); plus the
|
||||
* curated inherited `ctx` surface shared with the docs catalog. Source
|
||||
* pointers are dropped (a `file:line` means nothing to the model) and entries
|
||||
* are sorted deterministically.
|
||||
*
|
||||
* `tsx scripts/gen-cordis-api.ts` → write the artifact
|
||||
* `tsx scripts/gen-cordis-api.ts --check` → exit 1 if the committed file is
|
||||
* stale (CI / pre-push gate)
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
@@ -48,10 +29,8 @@ function quote(value: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Every exported `interface` / `type` declaration under `packages/<group>/<pkg>/src`,
|
||||
* printed without comments, keyed by name. A name declared in more than one
|
||||
* package (e.g. each plugin's `Config`) is ambiguous and dropped entirely —
|
||||
* serving the wrong package's shape is worse than serving none.
|
||||
* Collect exported interface and type shapes; omit names declared in multiple
|
||||
* packages rather than risk serving the wrong package's shape.
|
||||
*/
|
||||
function collectTypeDecls(scanRoot: string = root): Map<string, string> {
|
||||
const printer = ts.createPrinter({ removeComments: true })
|
||||
@@ -78,11 +57,7 @@ function collectTypeDecls(scanRoot: string = root): Map<string, string> {
|
||||
return decls
|
||||
}
|
||||
|
||||
/**
|
||||
* The transitive closure of type names referenced by the seed texts: every
|
||||
* collected declaration whose name appears (word-bounded) in a seed or in an
|
||||
* already-included declaration, sorted by name.
|
||||
*/
|
||||
/** Resolve and sort the word-bounded transitive type closure referenced by seed text. */
|
||||
function referencedTypes(seeds: string[], decls: Map<string, string>): { name: string; declaration: string }[] {
|
||||
const included = new Map<string, string>()
|
||||
let frontier = seeds
|
||||
|
||||
@@ -1,59 +1,12 @@
|
||||
/**
|
||||
* Generate (and verify) the cordis events and services catalogs in
|
||||
* docs/cordis-catalog/events.md and docs/cordis-catalog/services.md.
|
||||
*
|
||||
* The two pages are the WIRING-axis reference, one axis each: every cordis
|
||||
* event a plugin can listen to (exact signature + dispatch mode) and every
|
||||
* `ctx.<key>` service it can call (exact public interface). They complement the
|
||||
* core-data-structures catalog (the VOCABULARY axis — the types these
|
||||
* signatures move around).
|
||||
*
|
||||
* The catalogs are FULLY GENERATED from source — never hand-edit them. The
|
||||
* codebase is disciplined enough that a pure-AST pass captures the whole
|
||||
* truthful surface: every event/service is a string literal that round-trips
|
||||
* to a static `interface Events` / `interface Context` declaration (no
|
||||
* dynamically-named events, no runtime-only services). So the committed files
|
||||
* are build artifacts and a regenerate-and-diff freshness check (`--check`)
|
||||
* makes drift structurally impossible. Because generation enumerates source
|
||||
* rather than checking a hand-written subset, a brand-new event cannot be
|
||||
* silently undocumented — it appears in the next regenerate, and an
|
||||
* un-regenerated file fails `--check`.
|
||||
*
|
||||
* `tsx scripts/gen-cordis-catalog.ts` → write both catalogs
|
||||
* `tsx scripts/gen-cordis-catalog.ts --check` → exit 1 if a committed
|
||||
* catalog is stale (CI /
|
||||
* pre-push gate)
|
||||
*
|
||||
* The HARNESS tier (the `@deepseek-ai/dsh-*` events + services) is rendered in
|
||||
* full from source: signature, the `@mode` badge, and the declaration's JSDoc.
|
||||
* Every harness event MUST carry an `@mode emit|waterfall|parallel|serial` tag
|
||||
* — the generator hard-errors on a missing tag, and where the signature shape is
|
||||
* conclusive (a trailing `next: () => …` parameter is structurally a waterfall)
|
||||
* it asserts the tag agrees and hard-errors on a contradiction. Beyond the tag,
|
||||
* the walk enforces JSDoc COMPLETENESS on the whole harness surface (the
|
||||
* jsdoc-completeness-gate RFC): every event and public service method carries
|
||||
* description prose; every payload parameter has a non-empty `@param` (`this`
|
||||
* receivers and the trailing waterfall `next` are exempt — next's semantics are
|
||||
* documented once by the mode); a service method with a non-`void`/
|
||||
* `Promise<void>` return carries a non-empty `@returns` and needs an EXPLICIT
|
||||
* return type annotation (a pure-AST walk cannot classify an inferred return);
|
||||
* a stale `@param` naming no real parameter errors. Violations aggregate into
|
||||
* ONE error listing every offender. The tags are enforcement-only: parseJsDoc
|
||||
* stops prose at the first block tag, so they never change the rendered
|
||||
* catalog. The parsing + check helpers live in `scripts/jsdoc.ts`, shared with
|
||||
* the whole-export-surface gate (`scripts/verify-export-jsdoc.ts`) so
|
||||
* "documented" means the same thing on both surfaces. The INHERITED
|
||||
* tier (cordis core + loader/hmr/timer) is pinned vendor source a plugin author
|
||||
* also sees; it is rendered tersely (name + one-line + source pointer) from a
|
||||
* curated table in this script, NOT elevated to the harness tier's prominence.
|
||||
*
|
||||
* Signature fences use the ` ```ts cordis-catalog ` info string: doc-typecheck
|
||||
* recognizes it and skips compilation (the signatures are fragments, not
|
||||
* standalone-compilable, like the ` ```ts type-equiv ` blocks).
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts'
|
||||
|
||||
@@ -66,19 +19,11 @@ const OUT_SERVICES = 'docs/cordis-catalog/services.md'
|
||||
const FENCE = 'ts cordis-catalog'
|
||||
|
||||
/**
|
||||
* Cross-link map: a type name that appears in a signature → the
|
||||
* core-data-structures page that documents it (path relative to the catalogs'
|
||||
* folder).
|
||||
* Hand-curated and catalog-owned, NOT derived from type-equiv.manifest.json —
|
||||
* that manifest documents the `…Map` symbols (`ContentBlockMap`) while
|
||||
* signatures reference the derived UNION names (`ContentBlock`), and it lists a
|
||||
* few symbols on two pages. Here each name resolves to exactly one PRIMARY page.
|
||||
* Shared with `gen-config-catalog.ts` (each caller prefixes its own relative
|
||||
* path to `core-data-structures/`), so both catalogs cross-link identically.
|
||||
* TODO(catalog-type-links): add a verifier or generator for link-map coverage
|
||||
* so new hook-era decision types like `PromptDecision` / `PreToolDecision` do
|
||||
* not silently appear in signatures without a "Types:" link.
|
||||
* 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.
|
||||
*/
|
||||
// TODO(catalog-type-links): verify or generate link-map coverage.
|
||||
export const LINK_MAP: Record<string, string> = {
|
||||
Agent: 'core.md',
|
||||
ContentBlock: 'core.md',
|
||||
@@ -87,16 +32,23 @@ export const LINK_MAP: Record<string, string> = {
|
||||
GenerateOptions: 'core.md',
|
||||
LlmCallConfig: 'core.md',
|
||||
SessionEvent: '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',
|
||||
BashExecRequest: 'bash.md',
|
||||
BashExecSpec: 'bash.md',
|
||||
BashRunResult: 'bash.md',
|
||||
BashTask: 'bash.md',
|
||||
BashTaskRead: 'bash.md',
|
||||
ConfinedArgv: 'sandbox.md',
|
||||
SandboxMode: 'sandbox.md',
|
||||
SandboxPolicy: 'sandbox.md',
|
||||
CodeRunRequest: 'code-runtime.md',
|
||||
CodeRunResult: 'code-runtime.md',
|
||||
FsEditOutcome: 'filesystem.md',
|
||||
@@ -175,7 +127,7 @@ function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.Source
|
||||
export function collectEvents(scanRoot: string = root): EventEntry[] {
|
||||
const entries: EventEntry[] = []
|
||||
const violations: string[] = []
|
||||
for (const rel of globSync('packages/*/*/src/*.ts', { cwd: scanRoot }).sort()) {
|
||||
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 (!text.includes('interface Events')) continue
|
||||
@@ -207,10 +159,8 @@ export function collectEvents(scanRoot: string = root): EventEntry[] {
|
||||
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 each. Exempt the `this`
|
||||
// receiver annotation (not payload) and the trailing waterfall `next`
|
||||
// (mode machinery, documented once by @mode semantics). Documenting an
|
||||
// exempt parameter anyway is allowed — only absence is checked.
|
||||
// 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)
|
||||
@@ -231,7 +181,7 @@ export function collectEvents(scanRoot: string = root): EventEntry[] {
|
||||
export function collectServices(scanRoot: string = root): ServiceEntry[] {
|
||||
const entries: ServiceEntry[] = []
|
||||
const violations: string[] = []
|
||||
for (const rel of globSync('packages/*/*/src/index.ts', { cwd: scanRoot }).sort()) {
|
||||
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')
|
||||
if (!text.includes('interface Context')) continue
|
||||
@@ -261,10 +211,8 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
|
||||
const methods: string[] = []
|
||||
for (const member of cls.members) {
|
||||
if (!ts.isMethodDeclaration(member)) continue
|
||||
// Only the PUBLIC callable surface a `ctx.<key>` consumer sees. Drop
|
||||
// private/protected (a protected method like `notifyTaskDone` is a
|
||||
// subclass hook, not something a plugin calls through `ctx.bash`) and
|
||||
// static (not reachable through the instance).
|
||||
// Only instance methods callable through `ctx.<key>` are surface;
|
||||
// private, protected, and static methods are not.
|
||||
const nonPublic = member.modifiers?.some(m =>
|
||||
m.kind === ts.SyntaxKind.PrivateKeyword
|
||||
|| m.kind === ts.SyntaxKind.ProtectedKeyword
|
||||
|
||||
+363
-174
@@ -1,43 +1,24 @@
|
||||
/**
|
||||
* Generate (and verify) the relationship-diagram docs.
|
||||
*
|
||||
* This is the relationship layer above the existing catalogs:
|
||||
* - module-graph.md answers "which packages depend on which packages?"
|
||||
* - cordis-catalog/ answers "which events and services exist?"
|
||||
* - tool-catalog.md answers "which tools does the model see?"
|
||||
* - generated relationship diagrams answer "how do those pieces fit together?"
|
||||
*
|
||||
* Generated pages discover the enumerable facts from source. Hybrid pages use
|
||||
* discovered inventory plus small manifests for policy that source cannot infer
|
||||
* (for example, whether a package is an implementation or consumer in a seam).
|
||||
* Curated pages are still emitted here so the graph docs are one regenerated unit,
|
||||
* but their diagrams intentionally explain flow and ownership rather than
|
||||
* pretending to enumerate every source edge.
|
||||
*
|
||||
* `tsx scripts/gen-doc-graphs.ts` -> write generated diagram docs
|
||||
* `tsx scripts/gen-doc-graphs.ts --check` -> exit 1 if any file is stale
|
||||
* Generate the relationship layer above the module, Cordis, and tool catalogs.
|
||||
* Enumerable facts come from source; hybrid graphs add manifests for policy the
|
||||
* source cannot infer, while curated graphs explain flow and ownership.
|
||||
* `--check` verifies the generated set.
|
||||
*/
|
||||
|
||||
import { existsSync, globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, relative, resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { collectEvents, collectServices } from './gen-cordis-catalog.ts'
|
||||
import {
|
||||
collectPackageGraph,
|
||||
escapeMermaidLabel as escLabel,
|
||||
graphNodeId as nodeId,
|
||||
type PackageGraphNode,
|
||||
} from './package-graph.ts'
|
||||
import { TypeScriptProject } from './ts-project.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const SCOPE = '@deepseek-ai/dsh-'
|
||||
|
||||
interface PkgJson {
|
||||
name: string
|
||||
peerDependencies?: Record<string, string>
|
||||
}
|
||||
|
||||
interface Pkg {
|
||||
short: string
|
||||
name: string
|
||||
group: string
|
||||
rel: string
|
||||
deps: string[]
|
||||
}
|
||||
type Pkg = PackageGraphNode
|
||||
|
||||
interface GraphDoc {
|
||||
rel: string
|
||||
@@ -65,19 +46,32 @@ interface EventRelation {
|
||||
listeners: Set<string>
|
||||
}
|
||||
|
||||
interface PackageSource {
|
||||
rel: string
|
||||
pkg: string
|
||||
sourceFile: ts.SourceFile
|
||||
}
|
||||
|
||||
type EventReceiverKind = 'context' | 'agent-dispatch' | 'events-service'
|
||||
|
||||
const GROUP_ORDER = [
|
||||
'util',
|
||||
'llm',
|
||||
'core',
|
||||
'bash',
|
||||
'sandbox',
|
||||
'fs',
|
||||
'skill',
|
||||
'compact',
|
||||
'subagent',
|
||||
'tasks',
|
||||
'workflow',
|
||||
'web',
|
||||
'todo',
|
||||
'cordis',
|
||||
'hooks',
|
||||
'session-persistence',
|
||||
'session-query',
|
||||
'support',
|
||||
'ui',
|
||||
]
|
||||
@@ -97,7 +91,7 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
pkg: 'session',
|
||||
title: 'In-memory session store',
|
||||
mode: 'core',
|
||||
consumers: ['agent-loop', 'agent', 'session-persistence', 'subagent-inprocess', 'invariants'],
|
||||
consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'subagent-inprocess', 'invariants'],
|
||||
note: 'Owns append-only Session instances and emits the durable session event feed.',
|
||||
},
|
||||
{
|
||||
@@ -106,9 +100,16 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
title: 'Durable session persistence seam',
|
||||
mode: 'seam',
|
||||
implementations: ['session-persistence-jsonl', 'session-persistence-sqlite'],
|
||||
consumers: ['agent-loop', 'acp'],
|
||||
consumers: ['agent-loop', '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',
|
||||
mode: 'seam',
|
||||
note: 'Resolves live and optional persisted logs into one logical corpus for exact reads.',
|
||||
},
|
||||
{
|
||||
key: 'systemPrompt',
|
||||
pkg: 'system-prompt',
|
||||
@@ -120,26 +121,35 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
{
|
||||
key: 'tools',
|
||||
pkg: 'tools',
|
||||
title: 'Tool registry and execution waterfall',
|
||||
title: 'Tool registry and guarded execution pipeline',
|
||||
mode: 'core',
|
||||
consumers: ['agent-loop', 'tool-ask-user', 'tool-bash', 'tool-cordis', 'tool-fs', 'tool-subagent', 'tool-todo', 'tool-web', 'acp'],
|
||||
note: 'Registers tool definitions, exposes schemas to the prompt, and routes calls through tools/pre-execute and tools/post-execute.',
|
||||
consumers: ['agent-loop', 'tool-ask-user', 'tool-bash', 'tool-cordis', 'tool-fs', 'tool-skill', 'tool-subagent', 'tool-todo', 'tool-web', 'acp'],
|
||||
note: 'Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation.',
|
||||
},
|
||||
{
|
||||
key: 'userInteraction',
|
||||
pkg: 'user-interaction',
|
||||
title: 'Human question/answer seam',
|
||||
mode: 'seam',
|
||||
implementations: ['stdio-agent', 'acp'],
|
||||
consumers: ['tool-ask-user', 'stdio-agent', 'acp'],
|
||||
implementations: ['stdio-demo', 'acp'],
|
||||
consumers: ['tool-ask-user', 'stdio-demo', 'acp'],
|
||||
note: 'UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.',
|
||||
},
|
||||
{
|
||||
key: 'skills',
|
||||
pkg: 'skill',
|
||||
title: 'Skill provider registry',
|
||||
mode: 'seam',
|
||||
implementations: ['skill-local'],
|
||||
consumers: ['tool-skill'],
|
||||
note: 'Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies.',
|
||||
},
|
||||
{
|
||||
key: 'agents',
|
||||
pkg: 'agent',
|
||||
title: 'Agent registry',
|
||||
mode: 'core',
|
||||
consumers: ['agent-loop', 'acp', 'subagent-inprocess', 'stdio-agent', 'invariants'],
|
||||
consumers: ['agent-loop', 'acp', 'subagent-inprocess', 'stdio-demo', 'invariants'],
|
||||
note: 'Owns live Agent handles and the create/resume factory seam.',
|
||||
},
|
||||
{
|
||||
@@ -147,7 +157,7 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
pkg: 'agent-loop',
|
||||
title: 'Concrete loop driver',
|
||||
mode: 'bundle',
|
||||
consumers: ['agent-core'],
|
||||
consumers: ['agent-spine-demo'],
|
||||
note: 'The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package.',
|
||||
},
|
||||
{
|
||||
@@ -155,9 +165,36 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
pkg: 'bash',
|
||||
title: 'Bash executor seam',
|
||||
mode: 'seam',
|
||||
implementations: ['bash-local'],
|
||||
implementations: ['bash-local', 'bash-sandbox'],
|
||||
consumers: ['tool-bash', 'hooks-claude', 'hooks-codex'],
|
||||
note: 'The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors can replace bash-local.',
|
||||
note: 'The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them.',
|
||||
},
|
||||
{
|
||||
key: 'sandbox',
|
||||
pkg: 'sandbox',
|
||||
title: 'Process-sandbox seam',
|
||||
mode: 'seam',
|
||||
implementations: ['sandbox-local'],
|
||||
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: 'approval',
|
||||
pkg: 'approval',
|
||||
title: 'Approval seam',
|
||||
mode: 'seam',
|
||||
implementations: ['acp'],
|
||||
consumers: ['tools', 'tool-bash'],
|
||||
note: 'One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`.',
|
||||
},
|
||||
{
|
||||
key: 'permission',
|
||||
pkg: 'permission',
|
||||
title: 'Permission presets',
|
||||
mode: 'core',
|
||||
implementations: [],
|
||||
consumers: ['acp'],
|
||||
note: 'User-facing preset table (`workspace-write`/`danger-full-access`) bundling the sandbox-mode and approval-policy knobs; a switch writes one `permission/preset` event through to both knob events.',
|
||||
},
|
||||
{
|
||||
key: 'codeRuntime',
|
||||
@@ -196,6 +233,14 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
consumers: ['tool-subagent'],
|
||||
note: 'Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name.',
|
||||
},
|
||||
{
|
||||
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',
|
||||
pkg: 'web',
|
||||
@@ -216,22 +261,6 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const DYNAMIC_EVENT_DISPATCHERS: Array<{ event: string; pkg: string; method: string }> = [
|
||||
// Subagent lifecycle events intentionally bypass ctx.emit and call
|
||||
// ctx.events.dispatch directly so one throwing listener cannot starve later
|
||||
// listeners or strand an already-started child run.
|
||||
{ event: 'subagent/start', pkg: 'subagent', method: 'events.dispatch' },
|
||||
{ event: 'subagent/end', pkg: 'subagent', method: 'events.dispatch' },
|
||||
// The workflow/* lifecycle events dispatch the same way, for the same
|
||||
// per-listener-containment reason (WorkflowService.emitWorkflowEvent).
|
||||
{ event: 'workflow/start', pkg: 'workflow', method: 'events.dispatch' },
|
||||
{ event: 'workflow/phase', pkg: 'workflow', method: 'events.dispatch' },
|
||||
{ event: 'workflow/log', pkg: 'workflow', method: 'events.dispatch' },
|
||||
{ event: 'workflow/agent-start', pkg: 'workflow', method: 'events.dispatch' },
|
||||
{ event: 'workflow/agent-end', pkg: 'workflow', method: 'events.dispatch' },
|
||||
{ event: 'workflow/end', pkg: 'workflow', method: 'events.dispatch' },
|
||||
]
|
||||
|
||||
function generatedHeader(title: string): string[] {
|
||||
return [
|
||||
'<!-- Generated by scripts/gen-doc-graphs.ts - do not edit by hand.',
|
||||
@@ -254,62 +283,6 @@ function linkFromDoc(docRel: string, targetRel: string): string {
|
||||
return relative(dirname(docRel), targetRel).replaceAll('\\', '/')
|
||||
}
|
||||
|
||||
function collectPackages(): Pkg[] {
|
||||
const pkgs: Pkg[] = []
|
||||
for (const rel of globSync('packages/*/*/package.json', { cwd: root }).sort()) {
|
||||
const json = JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as PkgJson
|
||||
if (!json.name.startsWith(SCOPE)) continue
|
||||
const [, group, leaf] = rel.split('/')
|
||||
if (group === undefined || leaf === undefined) throw new Error(`gen-doc-graphs: unexpected package path ${rel}`)
|
||||
const deps = Object.keys(json.peerDependencies ?? {})
|
||||
.filter(dep => dep.startsWith(SCOPE))
|
||||
.map(dep => dep.slice(SCOPE.length))
|
||||
.sort()
|
||||
pkgs.push({
|
||||
short: json.name.slice(SCOPE.length),
|
||||
name: json.name,
|
||||
group,
|
||||
rel: dirname(rel),
|
||||
deps,
|
||||
})
|
||||
}
|
||||
return topoSort(pkgs)
|
||||
}
|
||||
|
||||
function topoSort(pkgs: Pkg[]): Pkg[] {
|
||||
const remaining = new Map(pkgs.map(p => [p.short, p]))
|
||||
const placed = new Set<string>()
|
||||
const out: Pkg[] = []
|
||||
while (remaining.size > 0) {
|
||||
const ready = [...remaining.values()]
|
||||
.filter(pkg => pkg.deps.every(dep => placed.has(dep)))
|
||||
.sort(comparePackages)
|
||||
if (ready.length === 0) throw new Error(`gen-doc-graphs: dependency cycle among ${[...remaining.keys()].join(', ')}`)
|
||||
for (const pkg of ready) {
|
||||
out.push(pkg)
|
||||
placed.add(pkg.short)
|
||||
remaining.delete(pkg.short)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function comparePackages(a: Pkg, b: Pkg): number {
|
||||
const groupA = GROUP_ORDER.indexOf(a.group)
|
||||
const groupB = GROUP_ORDER.indexOf(b.group)
|
||||
const normA = groupA === -1 ? Number.MAX_SAFE_INTEGER : groupA
|
||||
const normB = groupB === -1 ? Number.MAX_SAFE_INTEGER : groupB
|
||||
return normA - normB || a.group.localeCompare(b.group) || a.short.localeCompare(b.short)
|
||||
}
|
||||
|
||||
function nodeId(prefix: string, value: string): string {
|
||||
return `${prefix}_${value.replace(/[^a-zA-Z0-9_]/g, '_')}`
|
||||
}
|
||||
|
||||
function escLabel(value: string): string {
|
||||
return value.replace(/"/g, '\\"')
|
||||
}
|
||||
|
||||
function mermaidCode(value: string): string {
|
||||
return `<code>${value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</code>`
|
||||
}
|
||||
@@ -459,11 +432,11 @@ type AppExample = typeof APP_EXAMPLES[number]
|
||||
function renderAppExpansion(lines: string[], appNode: string, pluginName: string): void {
|
||||
const agentCore = nodeId('bundle', 'agent_core')
|
||||
const jsonl = nodeId('bundle', 'jsonl')
|
||||
lines.push(` ${appNode} --> ${agentCore}["@deepseek-ai/dsh-agent-core"]`)
|
||||
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-agent') {
|
||||
if (pluginName === '@deepseek-ai/dsh-stdio-demo') {
|
||||
lines.push(` ${appNode} --> ${nodeId('frontdoor', 'stdio')}["readline UI<br/>console logger<br/>pre-created main agent"]`)
|
||||
} else if (pluginName === '@deepseek-ai/dsh-acp-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"]`)
|
||||
}
|
||||
lines.push(
|
||||
@@ -489,7 +462,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-agent' || plugin.name === '@deepseek-ai/dsh-acp-agent') {
|
||||
if (plugin.name === '@deepseek-ai/dsh-stdio-demo' || plugin.name === '@deepseek-ai/dsh-acp-demo') {
|
||||
renderAppExpansion(lines, pluginNode, plugin.name)
|
||||
}
|
||||
}
|
||||
@@ -506,65 +479,256 @@ function renderAppComposition(example: AppExample): string {
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function collectEventRelations(): Map<string, EventRelation> {
|
||||
const out = new Map<string, EventRelation>()
|
||||
const ensure = (event: string): EventRelation => {
|
||||
const existing = out.get(event)
|
||||
if (existing) return existing
|
||||
const next = { dispatchers: new Map<string, Set<string>>(), listeners: new Set<string>() }
|
||||
out.set(event, next)
|
||||
return next
|
||||
/** Collect event dispatch/listener relations from real cross-file receiver types. */
|
||||
class EventRelationCollector {
|
||||
private readonly relations = new Map<string, EventRelation>()
|
||||
private readonly callSites = new Map<ts.SignatureDeclaration | ts.JSDocSignature, ts.CallExpression[]>()
|
||||
private readonly contextType: ts.Type
|
||||
private readonly agentDispatchType: ts.Type
|
||||
private readonly eventsServiceType: ts.Type
|
||||
|
||||
constructor(
|
||||
private readonly project: TypeScriptProject,
|
||||
private readonly sources: readonly PackageSource[],
|
||||
) {
|
||||
this.contextType = this.declaredType('vendor/cordis/src/context.ts', 'Context')
|
||||
this.agentDispatchType = this.declaredType('packages/core/agent/src/dispatch.ts', 'AgentEventDispatch')
|
||||
this.eventsServiceType = this.declaredType('vendor/cordis/src/events.ts', 'EventsService')
|
||||
this.indexCallSites()
|
||||
}
|
||||
for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: root }).sort()) {
|
||||
const [, , leaf] = rel.split('/')
|
||||
if (leaf === undefined) continue
|
||||
const text = readFileSync(resolve(root, rel), 'utf8')
|
||||
const sf = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true)
|
||||
|
||||
/** Return all event relations discovered from the Program. */
|
||||
collect(): Map<string, EventRelation> {
|
||||
for (const source of this.sources) this.visitSource(source)
|
||||
return this.relations
|
||||
}
|
||||
|
||||
/** Resolve one named class/interface declaration to its merged instance type. */
|
||||
private declaredType(relativePath: string, name: string): ts.Type {
|
||||
const sourceFile = this.project.sourceFile(relativePath)
|
||||
const declaration = sourceFile.statements.find((statement): statement is ts.ClassDeclaration | ts.InterfaceDeclaration => {
|
||||
return (ts.isClassDeclaration(statement) || ts.isInterfaceDeclaration(statement)) && statement.name?.text === name
|
||||
})
|
||||
const symbol = declaration?.name && this.project.checker.getSymbolAtLocation(declaration.name)
|
||||
if (!symbol) throw new Error(`cannot resolve TypeScript type ${name} from ${relativePath}`)
|
||||
return this.project.checker.getDeclaredTypeOfSymbol(symbol)
|
||||
}
|
||||
|
||||
/** Index resolved local function calls for narrow argument-flow recovery. */
|
||||
private indexCallSites(): void {
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isCallExpression(node)) {
|
||||
const declaration = this.project.checker.getResolvedSignature(node)?.declaration
|
||||
if (declaration) {
|
||||
const calls = this.callSites.get(declaration) ?? []
|
||||
calls.push(node)
|
||||
this.callSites.set(declaration, calls)
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
for (const source of this.sources) visit(source.sourceFile)
|
||||
}
|
||||
|
||||
/** Walk one package source file and classify event API calls by receiver type. */
|
||||
private visitSource(source: PackageSource): void {
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
||||
const receiverKind = this.receiverKind(node.expression.expression)
|
||||
const method = node.expression.name.text
|
||||
if (!isCordisContextReceiver(node.expression, sf)) {
|
||||
ts.forEachChild(node, visit)
|
||||
return
|
||||
}
|
||||
if (method === 'on') {
|
||||
const event = eventArg(node.arguments, method)
|
||||
if (event) ensure(event).listeners.add(leaf)
|
||||
} else if (method === 'emit' || method === 'parallel' || method === 'serial' || method === 'waterfall') {
|
||||
const event = eventArg(node.arguments, method)
|
||||
if (event) {
|
||||
const relation = ensure(event)
|
||||
const methods = relation.dispatchers.get(leaf) ?? new Set<string>()
|
||||
methods.add(method)
|
||||
relation.dispatchers.set(leaf, methods)
|
||||
if (receiverKind === 'events-service' && method === 'dispatch') {
|
||||
const argumentList = node.arguments[1]
|
||||
if (argumentList) {
|
||||
for (const event of this.eventNamesFromArgumentList(argumentList, new Set())) {
|
||||
this.addDispatcher(event, source.pkg, 'events.dispatch')
|
||||
}
|
||||
}
|
||||
} else if (receiverKind === 'context' || receiverKind === 'agent-dispatch') {
|
||||
const eventNames = this.eventNamesFromCall(node, receiverKind)
|
||||
if (method === 'on' || method === 'once') {
|
||||
for (const event of eventNames) this.ensure(event).listeners.add(source.pkg)
|
||||
} else if (method === 'emit' || method === 'parallel' || method === 'serial' || method === 'waterfall') {
|
||||
for (const event of eventNames) this.addDispatcher(event, source.pkg, method)
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(sf)
|
||||
visit(source.sourceFile)
|
||||
}
|
||||
for (const entry of DYNAMIC_EVENT_DISPATCHERS) {
|
||||
const relation = ensure(entry.event)
|
||||
const methods = relation.dispatchers.get(entry.pkg) ?? new Set<string>()
|
||||
methods.add(entry.method)
|
||||
relation.dispatchers.set(entry.pkg, methods)
|
||||
|
||||
/** Classify a receiver using assignability to the repository's actual event API types. */
|
||||
private receiverKind(receiver: ts.Expression): EventReceiverKind | undefined {
|
||||
const type = this.project.checker.getTypeAtLocation(receiver)
|
||||
if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never)) return undefined
|
||||
if (this.project.checker.isTypeAssignableTo(type, this.eventsServiceType)) return 'events-service'
|
||||
if (this.project.checker.isTypeAssignableTo(type, this.contextType)) return 'context'
|
||||
if (this.project.checker.isTypeAssignableTo(type, this.agentDispatchType)) return 'agent-dispatch'
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Resolve the event-name argument for Context and fused agent dispatch calls. */
|
||||
private eventNamesFromCall(call: ts.CallExpression, receiverKind: Exclude<EventReceiverKind, 'events-service'>): Set<string> {
|
||||
const candidates = receiverKind === 'context' ? call.arguments.slice(0, 2) : call.arguments.slice(0, 1)
|
||||
for (const candidate of candidates) {
|
||||
const values = this.finiteStringValues(candidate)
|
||||
if (values) return values
|
||||
}
|
||||
return new Set()
|
||||
}
|
||||
|
||||
/** Recover the event slot from the argument array handed to EventsService.dispatch(). */
|
||||
private eventNamesFromArgumentList(expression: ts.Expression, seen: Set<ts.Node>): Set<string> {
|
||||
const current = unwrapExpression(expression)
|
||||
if (seen.has(current)) return new Set()
|
||||
seen.add(current)
|
||||
|
||||
if (ts.isArrayLiteralExpression(current)) {
|
||||
for (const element of current.elements.slice(0, 2)) {
|
||||
if (ts.isOmittedExpression(element) || ts.isSpreadElement(element)) continue
|
||||
const values = this.finiteStringValues(element)
|
||||
if (values) return values
|
||||
}
|
||||
return new Set()
|
||||
}
|
||||
if (ts.isConditionalExpression(current)) {
|
||||
return unionSets(
|
||||
this.eventNamesFromArgumentList(current.whenTrue, new Set(seen)),
|
||||
this.eventNamesFromArgumentList(current.whenFalse, new Set(seen)),
|
||||
)
|
||||
}
|
||||
if (!ts.isIdentifier(current)) return new Set()
|
||||
|
||||
const symbol = this.project.checker.getSymbolAtLocation(current)
|
||||
if (!symbol) return new Set()
|
||||
const events = new Set<string>()
|
||||
for (const declaration of symbol.declarations ?? []) {
|
||||
if (ts.isVariableDeclaration(declaration) && declaration.initializer && isConstDeclaration(declaration)) {
|
||||
addAll(events, this.eventNamesFromArgumentList(declaration.initializer, new Set(seen)))
|
||||
} else if (ts.isParameter(declaration)) {
|
||||
addAll(events, this.eventNamesFromParameter(declaration, seen))
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
/** Follow a non-exported local helper parameter back to every resolved call site. */
|
||||
private eventNamesFromParameter(parameter: ts.ParameterDeclaration, seen: Set<ts.Node>): Set<string> {
|
||||
const owner = parameter.parent
|
||||
if (!ts.isFunctionDeclaration(owner) || hasExportModifier(owner)) return new Set()
|
||||
const index = owner.parameters.indexOf(parameter)
|
||||
if (index < 0) return new Set()
|
||||
const events = new Set<string>()
|
||||
for (const call of this.callSites.get(owner) ?? []) {
|
||||
const argument = call.arguments[index]
|
||||
if (argument) addAll(events, this.eventNamesFromArgumentList(argument, new Set(seen)))
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
/** Return a finite string-literal value set, rejecting widened and generic strings. */
|
||||
private finiteStringValues(expression: ts.Expression): Set<string> | undefined {
|
||||
const current = unwrapExpression(expression)
|
||||
if (ts.isStringLiteralLike(current)) return new Set([current.text])
|
||||
if (this.isForwardedAgentEventParameter(current)) return undefined
|
||||
return finiteStringTypeValues(this.project.checker.getTypeAtLocation(current))
|
||||
}
|
||||
|
||||
/** Reject the contextual parameter inside the AgentEventDispatch forwarding object. */
|
||||
private isForwardedAgentEventParameter(expression: ts.Expression): boolean {
|
||||
if (!ts.isIdentifier(expression)) return false
|
||||
const declarations = this.project.checker.getSymbolAtLocation(expression)?.declarations ?? []
|
||||
return declarations.some((declaration) => {
|
||||
if (!ts.isParameter(declaration)) return false
|
||||
const method = declaration.parent
|
||||
if (!ts.isMethodDeclaration(method) || !ts.isObjectLiteralExpression(method.parent)) return false
|
||||
const contextualType = this.project.checker.getContextualType(method.parent)
|
||||
return contextualType !== undefined
|
||||
&& this.project.checker.isTypeAssignableTo(contextualType, this.agentDispatchType)
|
||||
})
|
||||
}
|
||||
|
||||
/** Get or create one relation row. */
|
||||
private ensure(event: string): EventRelation {
|
||||
const existing = this.relations.get(event)
|
||||
if (existing) return existing
|
||||
const relation = { dispatchers: new Map<string, Set<string>>(), listeners: new Set<string>() }
|
||||
this.relations.set(event, relation)
|
||||
return relation
|
||||
}
|
||||
|
||||
/** Add one dispatcher method without duplicating package/method labels. */
|
||||
private addDispatcher(event: string, pkg: string, method: string): void {
|
||||
const relation = this.ensure(event)
|
||||
const methods = relation.dispatchers.get(pkg) ?? new Set<string>()
|
||||
methods.add(method)
|
||||
relation.dispatchers.set(pkg, methods)
|
||||
}
|
||||
}
|
||||
|
||||
/** Peel syntax-only wrappers that do not change an expression's runtime value. */
|
||||
function unwrapExpression(expression: ts.Expression): ts.Expression {
|
||||
let current = expression
|
||||
while (
|
||||
ts.isParenthesizedExpression(current)
|
||||
|| ts.isAsExpression(current)
|
||||
|| ts.isTypeAssertionExpression(current)
|
||||
|| ts.isNonNullExpression(current)
|
||||
|| ts.isSatisfiesExpression(current)
|
||||
) {
|
||||
current = current.expression
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
/** Return every value only when a type is a closed string-literal union. */
|
||||
function finiteStringTypeValues(type: ts.Type): Set<string> | undefined {
|
||||
if (type.flags & ts.TypeFlags.StringLiteral) {
|
||||
return new Set([(type as ts.StringLiteralType).value])
|
||||
}
|
||||
if (type.flags & ts.TypeFlags.Never) return new Set()
|
||||
if (!type.isUnion()) return undefined
|
||||
const values = new Set<string>()
|
||||
for (const member of type.types) {
|
||||
const memberValues = finiteStringTypeValues(member)
|
||||
if (!memberValues) return undefined
|
||||
addAll(values, memberValues)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
/** Return whether a variable declaration belongs to a const declaration list. */
|
||||
function isConstDeclaration(declaration: ts.VariableDeclaration): boolean {
|
||||
return (declaration.parent.flags & ts.NodeFlags.Const) !== 0
|
||||
}
|
||||
|
||||
/** Return whether a declaration is visible to callers outside its source module. */
|
||||
function hasExportModifier(node: ts.Node): boolean {
|
||||
return ts.canHaveModifiers(node) && (ts.getModifiers(node)?.some((modifier) => {
|
||||
return modifier.kind === ts.SyntaxKind.ExportKeyword || modifier.kind === ts.SyntaxKind.DefaultKeyword
|
||||
}) ?? false)
|
||||
}
|
||||
|
||||
/** Add every member of source to target. */
|
||||
function addAll<T>(target: Set<T>, source: ReadonlySet<T>): void {
|
||||
for (const value of source) target.add(value)
|
||||
}
|
||||
|
||||
/** Return the union of two sets without mutating either input. */
|
||||
function unionSets<T>(left: ReadonlySet<T>, right: ReadonlySet<T>): Set<T> {
|
||||
const out = new Set(left)
|
||||
addAll(out, right)
|
||||
return out
|
||||
}
|
||||
|
||||
function isCordisContextReceiver(expr: ts.PropertyAccessExpression, sf: ts.SourceFile): boolean {
|
||||
const target = expr.expression.getText(sf)
|
||||
return target === 'ctx' || target === 'this.ctx'
|
||||
}
|
||||
|
||||
function eventArg(args: ts.NodeArray<ts.Expression>, method: string): string | undefined {
|
||||
if (method === 'waterfall') {
|
||||
const arg = args.find(ts.isStringLiteralLike)
|
||||
return arg?.text
|
||||
}
|
||||
const first = args[0]
|
||||
return first && ts.isStringLiteralLike(first) ? first.text : undefined
|
||||
function collectEventRelations(): Map<string, EventRelation> {
|
||||
const project = new TypeScriptProject(root)
|
||||
const sources = project.sourceFiles().flatMap((sourceFile): PackageSource[] => {
|
||||
const rel = project.relativePath(sourceFile)
|
||||
const match = /^packages\/[^/]+\/([^/]+)\/src\/.+\.ts$/.exec(rel)
|
||||
return match?.[1] ? [{ rel, pkg: match[1], sourceFile }] : []
|
||||
}).sort((left, right) => left.rel.localeCompare(right.rel))
|
||||
return new EventRelationCollector(project, sources).collect()
|
||||
}
|
||||
|
||||
function relationPackages(map: Map<string, Set<string>>, pkgsByShort: Map<string, Pkg>): string {
|
||||
@@ -584,10 +748,10 @@ function renderEventRelations(pkgs: Pkg[]): string {
|
||||
const events = collectEvents()
|
||||
const relations = collectEventRelations()
|
||||
const pkgsByShort = new Map(pkgs.map(pkg => [pkg.short, pkg]))
|
||||
const maintenance = 'hybrid generated: Cordis event declarations and most producer/listener edges are AST-scanned; dynamic dispatch sites are classified in `scripts/gen-doc-graphs.ts`'
|
||||
const maintenance = 'generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program'
|
||||
const lines = generatedHeader('Event Producer And Consumer Matrix')
|
||||
lines.push(
|
||||
'This matrix shows which packages dispatch each harness-owned event and which packages listen to it. It is intentionally a table rather than one large graph: events are many-to-many, and dense relation data is easier to review in rows. Dynamic dispatch overrides cover sites that deliberately bypass `ctx.emit`, such as subagent lifecycle containment.',
|
||||
'This matrix shows which packages dispatch each harness-owned event and which packages listen to it. It is intentionally a table rather than one large graph: events are many-to-many, and dense relation data is easier to review in rows. Receiver and event-name types also cover contained dispatch sites that deliberately bypass `ctx.emit`, such as subagent lifecycle containment.',
|
||||
'',
|
||||
'| Event | Mode | Declared in | Dispatchers | Listeners |',
|
||||
'| --- | --- | --- | --- | --- |',
|
||||
@@ -596,6 +760,19 @@ function renderEventRelations(pkgs: Pkg[]): string {
|
||||
const relation = relations.get(event.name) ?? { dispatchers: new Map<string, Set<string>>(), listeners: new Set<string>() }
|
||||
lines.push(`| \`${event.name}\` | \`${event.mode}\` | ${sourceLink(event.source)} | ${relationPackages(relation.dispatchers, pkgsByShort)} | ${listenerPackages(relation.listeners, pkgsByShort)} |`)
|
||||
}
|
||||
// Every declared event needs a dispatcher: zero means dead vocabulary or an
|
||||
// unrecognized semantic dispatch shape. Listener-free extension points remain valid.
|
||||
const undispatched = [...events]
|
||||
.filter(event => (relations.get(event.name)?.dispatchers.size ?? 0) === 0)
|
||||
.map(event => event.name)
|
||||
.sort()
|
||||
if (undispatched.length > 0) {
|
||||
throw new Error(
|
||||
`event-producer-consumer matrix: no dispatcher found for declared event${undispatched.length > 1 ? 's' : ''} `
|
||||
+ `${undispatched.map(name => `"${name}"`).join(', ')} — dead vocabulary, or a dispatch shape the semantic scan misses `
|
||||
+ '(teach scripts/gen-doc-graphs.ts the shape)',
|
||||
)
|
||||
}
|
||||
const declared = new Set(events.map(event => event.name))
|
||||
const extra = [...relations.keys()].filter(event => !declared.has(event)).sort()
|
||||
if (extra.length > 0) {
|
||||
@@ -650,6 +827,7 @@ function renderLifecycle(): string {
|
||||
' Tools-->>Session: tool-owned events when applicable',
|
||||
` Driver->>Session: ${mermaidCode('tool/result')} and ${mermaidCode('step/end')}`,
|
||||
` Driver->>Hooks: ${mermaidCode('agent/turn-continuation')} waterfall`,
|
||||
` Driver->>Hooks: ${mermaidCode('agent/turn-stop')} serial terminal checkpoint`,
|
||||
` Driver->>Session: ${mermaidCode('turn/end')}`,
|
||||
` Driver->>Persistence: ${mermaidCode('session/flush')} parallel checkpoint`,
|
||||
` Driver-->>SDK: ${mermaidCode('agent/status')} idle`,
|
||||
@@ -665,7 +843,7 @@ function renderToolPipeline(): string {
|
||||
const maintenance = 'curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs'
|
||||
return [
|
||||
...generatedHeader('Tool Execution Pipeline'),
|
||||
'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, and UI rendering fit without changing the loop. The key extension points are the `tools/pre-execute`, `tools/execute`, and `tools/post-execute` waterfalls.',
|
||||
'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering fit without changing the loop. The transformable extension points are the `tools/pre-execute`, `tools/execute`, and `tools/post-execute` waterfalls; monotonic guards and `tools/result` are the owner-enforced boundaries around them.',
|
||||
'',
|
||||
'```mermaid',
|
||||
'flowchart TD',
|
||||
@@ -673,33 +851,44 @@ function renderToolPipeline(): string {
|
||||
` toolCall["Session event: ${mermaidCode('tool/call')}<br/>logged before execution"]`,
|
||||
' presentCall["UI pending card<br/>presentCall(args)"]',
|
||||
` pre["${mermaidCode('tools/pre-execute')} waterfall<br/>hooks, permission, sandbox"]`,
|
||||
' denied["deny or ask<br/>tool body skipped"]',
|
||||
' guards["Registered monotonic guards<br/>deny or abstain; identity protected"]',
|
||||
' denied["denied or approval refused<br/>tool body skipped"]',
|
||||
` approval["${mermaidCode('ctx.approval')} one-shot prompt<br/>absent or unanswerable: deny"]`,
|
||||
` around["${mermaidCode('tools/execute')} waterfall<br/>timeout, retry, metrics (around dispatch)"]`,
|
||||
' toolBody["Registered tool execute() body"]',
|
||||
` fsGate["${mermaidCode('fs/write-intent')} or ${mermaidCode('fs/edit-intent')}<br/>tool-fs mutations only"]`,
|
||||
` owned["Tool-owned session events<br/>${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${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"]',
|
||||
` toolResult["Session event: ${mermaidCode('tool/result')}<br/>single model-facing outcome"]`,
|
||||
' allResults["All calls in the step settled<br/>and tool/result events recorded"]',
|
||||
' presentResult["UI completed card<br/>presentResult(args, result)"]',
|
||||
' model --> toolCall',
|
||||
' toolCall --> presentCall',
|
||||
' toolCall --> pre',
|
||||
' pre -->|allow| around',
|
||||
' pre -->|allow| guards',
|
||||
' guards -->|allow| around',
|
||||
' guards -->|deny| denied',
|
||||
' around --> toolBody',
|
||||
' pre -->|deny or ask| denied',
|
||||
' pre -->|deny| denied',
|
||||
' pre -->|ask| approval',
|
||||
' approval -->|allowed-once| guards',
|
||||
' approval -->|rejected, cancelled, unavailable| denied',
|
||||
' denied --> post',
|
||||
' toolBody --> fsGate',
|
||||
' fsGate --> toolBody',
|
||||
' toolBody --> owned',
|
||||
' toolBody --> around',
|
||||
' around --> post',
|
||||
' post --> context',
|
||||
' post --> toolResult',
|
||||
' post --> final',
|
||||
' final --> toolResult',
|
||||
' toolResult --> presentResult',
|
||||
' toolResult --> allResults',
|
||||
' allResults --> context',
|
||||
'```',
|
||||
'',
|
||||
'Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and future permission prompts live on the generic pre/post tool waterfalls; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service. Code Mode rides the same pipeline twice over: `run_code` is itself a registered tool body, and each tool call its program makes re-enters `ctx.tools.execute()` through BOTH waterfalls — serialized one at a time, logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call\'s `additionalContext` is deliberately dropped — no safe outlet mid-run preserves 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 `additionalContext` to preserve call/result adjacency.',
|
||||
'',
|
||||
...maintenanceFooter(maintenance),
|
||||
].join('\n')
|
||||
@@ -735,7 +924,7 @@ function renderSnapshotReplay(): string {
|
||||
}
|
||||
|
||||
function renderDocs(): GraphDoc[] {
|
||||
const pkgs = collectPackages()
|
||||
const pkgs = collectPackageGraph(root, GROUP_ORDER, 'gen-doc-graphs')
|
||||
const docs: GraphDoc[] = [
|
||||
{ rel: 'docs/capability-seams.md', content: renderCapabilitySeams(pkgs) },
|
||||
...APP_EXAMPLES.map(example => ({ rel: example.rel, content: renderAppComposition(example) })),
|
||||
|
||||
+17
-97
@@ -1,40 +1,21 @@
|
||||
/**
|
||||
* Generate (and verify) the module dependency graph in docs/module-graph.md.
|
||||
*
|
||||
* The architectural shape of the harness lives implicitly in each package's
|
||||
* `peerDependencies` — the canonical runtime-dependency signal (devDeps mirror
|
||||
* these as `workspace:^` plus test-only extras, which would add noise). This
|
||||
* script reads every `packages/* /* /package.json`, keeps only the
|
||||
* `@deepseek-ai/dsh-*` peer edges (dropping the `cordis` peer), and renders a
|
||||
* GitHub-viewable Mermaid graph grouped by `packages/<group>/` plus a
|
||||
* dependency table.
|
||||
*
|
||||
* The file is fully generated — never hand-edit it. Output is deterministic
|
||||
* (packages and edges sorted) so a regenerate-and-diff freshness check is
|
||||
* stable.
|
||||
*
|
||||
* `tsx scripts/gen-module-graph.ts` → write docs/module-graph.md
|
||||
* `tsx scripts/gen-module-graph.ts --check` → exit 1 if the committed file
|
||||
* is stale (CI / pre-push gate)
|
||||
* Generate `docs/module-graph.md` from in-repo `peerDependencies`, the canonical
|
||||
* runtime edges. The deterministic output groups packages by directory and
|
||||
* renders both Mermaid and a dependency table; `--check` verifies freshness.
|
||||
*/
|
||||
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
collectPackageGraph,
|
||||
escapeMermaidLabel as escLabel,
|
||||
graphNodeId as nodeId,
|
||||
type PackageGraphNode,
|
||||
} from './package-graph.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'docs/module-graph.md'
|
||||
const SCOPE = '@deepseek-ai/dsh-'
|
||||
|
||||
interface Pkg {
|
||||
/** Short name, `@deepseek-ai/dsh-` prefix stripped (e.g. `agent-loop`). */
|
||||
short: string
|
||||
/** Package group from `packages/<group>/<pkg>`. */
|
||||
group: string
|
||||
/** Repo-relative package directory. */
|
||||
rel: string
|
||||
/** Short names of this package's in-repo peer dependencies, sorted. */
|
||||
deps: string[]
|
||||
}
|
||||
type Pkg = PackageGraphNode
|
||||
|
||||
const GROUP_ORDER = [
|
||||
'util',
|
||||
@@ -42,6 +23,7 @@ const GROUP_ORDER = [
|
||||
'core',
|
||||
'bash',
|
||||
'fs',
|
||||
'skill',
|
||||
'compact',
|
||||
'subagent',
|
||||
'web',
|
||||
@@ -50,71 +32,11 @@ const GROUP_ORDER = [
|
||||
'cordis',
|
||||
'hooks',
|
||||
'session-persistence',
|
||||
'session-query',
|
||||
'support',
|
||||
'ui',
|
||||
]
|
||||
|
||||
/** Read every workspace package and its `@deepseek-ai/dsh-*` peer edges. */
|
||||
function collect(): Pkg[] {
|
||||
const pkgs: Pkg[] = []
|
||||
for (const rel of globSync('packages/*/*/package.json', { cwd: root })) {
|
||||
const json = JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as {
|
||||
name: string
|
||||
peerDependencies?: Record<string, string>
|
||||
}
|
||||
if (!json.name.startsWith(SCOPE)) continue
|
||||
const deps = Object.keys(json.peerDependencies ?? {})
|
||||
.filter(d => d.startsWith(SCOPE))
|
||||
.map(d => d.slice(SCOPE.length))
|
||||
.sort()
|
||||
const [, group, leaf] = rel.split('/')
|
||||
if (group === undefined || leaf === undefined) throw new Error(`gen-module-graph: unexpected package path ${rel}`)
|
||||
pkgs.push({ short: json.name.slice(SCOPE.length), group, rel: dirname(rel), deps })
|
||||
}
|
||||
return topoSort(pkgs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Order packages low-level → high-level: a package appears only after every
|
||||
* package it depends on. Kahn-style layering with an alphabetical tiebreak
|
||||
* within each layer, so the output stays deterministic (the freshness check
|
||||
* compares whole-file). The graph is a DAG, so this always terminates; a cycle
|
||||
* would leave nodes unplaced and throw.
|
||||
*/
|
||||
function topoSort(pkgs: Pkg[]): Pkg[] {
|
||||
const remaining = new Map(pkgs.map(p => [p.short, p]))
|
||||
const placed = new Set<string>()
|
||||
const out: Pkg[] = []
|
||||
while (remaining.size > 0) {
|
||||
const ready = [...remaining.values()]
|
||||
.filter(p => p.deps.every(d => placed.has(d)))
|
||||
.sort(comparePackages)
|
||||
if (ready.length === 0) throw new Error(`gen-module-graph: dependency cycle among ${[...remaining.keys()].join(', ')}`)
|
||||
for (const p of ready) {
|
||||
out.push(p)
|
||||
placed.add(p.short)
|
||||
remaining.delete(p.short)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function comparePackages(a: Pkg, b: Pkg): number {
|
||||
const groupA = GROUP_ORDER.indexOf(a.group)
|
||||
const groupB = GROUP_ORDER.indexOf(b.group)
|
||||
const normA = groupA === -1 ? Number.MAX_SAFE_INTEGER : groupA
|
||||
const normB = groupB === -1 ? Number.MAX_SAFE_INTEGER : groupB
|
||||
return normA - normB || a.group.localeCompare(b.group) || a.short.localeCompare(b.short)
|
||||
}
|
||||
|
||||
function nodeId(prefix: string, value: string): string {
|
||||
return `${prefix}_${value.replace(/[^a-zA-Z0-9_]/g, '_')}`
|
||||
}
|
||||
|
||||
function escLabel(value: string): string {
|
||||
return value.replace(/"/g, '\\"')
|
||||
}
|
||||
|
||||
function packageLink(pkg: Pkg): string {
|
||||
return `[\`${pkg.short}\`](../${pkg.rel})`
|
||||
}
|
||||
@@ -169,17 +91,15 @@ function render(pkgs: Pkg[]): string {
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
const content = render(collect())
|
||||
const content = render(collectPackageGraph(root, GROUP_ORDER, 'gen-module-graph'))
|
||||
|
||||
if (process.argv.includes('--check')) {
|
||||
let committed: string | null = null
|
||||
try {
|
||||
committed = readFileSync(resolve(root, OUT), 'utf8')
|
||||
} catch {
|
||||
// Only an ENOENT (file not yet generated) is expected here; readFileSync of
|
||||
// a present-but-unreadable file is not a state this repo produces. Either
|
||||
// way the remedy is the same — regenerate — so we treat a read failure as
|
||||
// "stale" and fall through to the failure branch below.
|
||||
// A missing artifact is the expected read failure. Any read failure has the
|
||||
// same remedy here—regenerate—so it is reported as stale below.
|
||||
committed = null
|
||||
}
|
||||
if (committed === content) {
|
||||
|
||||
@@ -1,50 +1,15 @@
|
||||
/**
|
||||
* Generate (and verify) the persistence log event catalog in
|
||||
* docs/persistence-catalog.md.
|
||||
*
|
||||
* The catalog is the ON-DISK-vocabulary reference: every event type that can
|
||||
* appear in a session's durable event log — every member of the
|
||||
* merge-extensible `SessionEventMap`, across the owning declaration in
|
||||
* `@deepseek-ai/dsh-session` and every plugin declaration merge. It complements
|
||||
* the cordis events/services catalog (the live bus wiring — a log event is NOT
|
||||
* a cordis event; it reaches listeners via the single `session/event` emit) and
|
||||
* the core-data-structures session page (the `SessionEvent` envelope and
|
||||
* derivation semantics): this page is the RECORDS a persisted log can contain.
|
||||
*
|
||||
* `tsx scripts/gen-persistence-catalog.ts` → write the catalog
|
||||
* `tsx scripts/gen-persistence-catalog.ts --check` → exit 1 if the committed
|
||||
* file is stale (CI /
|
||||
* pre-push gate)
|
||||
*
|
||||
* Like its AST sibling `gen-cordis-catalog.ts` (and unlike the boot-based
|
||||
* `gen-tool-catalog.ts`), this is a pure source pass: every log event is a
|
||||
* string-literal-named property with a static type annotation, so the AST is
|
||||
* the whole truth and a brand-new event (core or merged) appears in the next
|
||||
* regenerate — an un-regenerated file fails `--check`. The walk enforces JSDoc
|
||||
* COMPLETENESS on the whole vocabulary: every member carries description prose
|
||||
* (it becomes the catalog entry), and an `@mode` tag on a member is a hard
|
||||
* error — dispatch modes belong to cordis bus events, and a log event has none
|
||||
* (see docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md).
|
||||
* Structural holes are hard errors for the same reason: a member that is not a
|
||||
* property signature with an explicit payload type, an `extends` clause on a
|
||||
* declaration, a top-level `interface SessionEventMap` that is not the single
|
||||
* exported declaration in the owning package, and a duplicate declaration of
|
||||
* one event would each let something join (or impersonate)
|
||||
* `keyof SessionEventMap` without a truthful catalog row. Violations aggregate
|
||||
* into ONE error listing every offender.
|
||||
*
|
||||
* The surface/log-only badge is parsed from the `SurfaceEventType` union in the
|
||||
* owning package (never hand-listed here), and every union member must name a
|
||||
* collected event — a stale union member is a hard error.
|
||||
*
|
||||
* Payload fences use the ` ```ts persistence-catalog ` info string:
|
||||
* doc-typecheck recognizes it and skips compilation (a bare payload fragment is
|
||||
* not standalone-compilable), excluded from the opt-out ratio.
|
||||
* 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,
|
||||
* documented, inheritance-free, and free of Cordis-only `@mode` tags; every
|
||||
* surface-union member must resolve to one. `--check` verifies the artifact.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { parseJsDoc, pointer, rawJsDoc, reportViolations } from './jsdoc.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'docs/persistence-catalog.md'
|
||||
@@ -56,14 +21,7 @@ const FENCE = 'ts persistence-catalog'
|
||||
/** The package whose module id plugin merges augment (`declare module '…'`). */
|
||||
const SESSION_MODULE = '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* Cross-link map: a type name that appears in a payload → the
|
||||
* core-data-structures page that documents it (path relative to OUT's folder).
|
||||
* Hand-curated and catalog-owned, same policy as the cordis catalog's map: each
|
||||
* name resolves to exactly one PRIMARY page. A payload type with no
|
||||
* core-data-structures home (e.g. `HookDialect`, documented in its package)
|
||||
* simply gets no link.
|
||||
*/
|
||||
/** Primary core-data-structures page for linked payload types. */
|
||||
const LINK_MAP: Record<string, string> = {
|
||||
CallId: 'core.md',
|
||||
ContentBlock: 'core.md',
|
||||
@@ -95,21 +53,12 @@ export interface AnnotatedLogEventEntry extends LogEventEntry {
|
||||
surface: boolean
|
||||
}
|
||||
|
||||
/** Repo-relative source pointer `file:line` for a node's first character. */
|
||||
function pointer(rel: string, sf: ts.SourceFile, node: ts.Node): string {
|
||||
const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf))
|
||||
return `${rel}:${line + 1}`
|
||||
}
|
||||
|
||||
const printer = ts.createPrinter({ removeComments: true })
|
||||
|
||||
/**
|
||||
* One-line payload text for a member's type annotation. Printed through the
|
||||
* TypeScript printer (not sliced from source text): the printer emits `;`
|
||||
* member separators regardless of how the source separated them, so a
|
||||
* multi-line newline-separated type literal still collapses to a VALID
|
||||
* single-line fragment. The trailing `;` the printer puts before every `}` is
|
||||
* dropped to match the repo's inline-literal style.
|
||||
* Render a member type on one line through the TypeScript printer, which adds
|
||||
* semicolon separators. Drop its trailing semicolon before `}` to match the
|
||||
* repository's inline-literal style.
|
||||
*/
|
||||
function payloadText(type: ts.TypeNode, sf: ts.SourceFile): string {
|
||||
return printer.printNode(ts.EmitHint.Unspecified, type, sf)
|
||||
@@ -118,87 +67,6 @@ function payloadText(type: ts.TypeNode, sf: ts.SourceFile): string {
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** The raw `/** … */` JSDoc block immediately preceding a node, or '' if none. */
|
||||
function rawJsDoc(text: string, node: ts.Node): string {
|
||||
const ranges = ts.getLeadingCommentRanges(text, node.getFullStart()) ?? []
|
||||
const jsdoc = ranges.filter(r => text.slice(r.pos, r.pos + 3) === '/**').at(-1)
|
||||
return jsdoc ? text.slice(jsdoc.pos, jsdoc.end) : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw JSDoc block into description prose, flagging whether any `@mode`
|
||||
* tag is present (forbidden on log events). Output obeys the repo's markdown
|
||||
* conventions so the generated file passes verify-md-wrap: each prose paragraph
|
||||
* collapses to ONE physical line, and a `-` bullet list is preserved with each
|
||||
* item on its own single line (continuation lines folded in). `{@link Foo}`
|
||||
* unwraps to `Foo`. Description prose ends at the FIRST block tag (standard
|
||||
* JSDoc semantics): tag lines and their continuation lines are never prose.
|
||||
*/
|
||||
function parseJsDoc(raw: string): { doc: string; hasMode: boolean } {
|
||||
const inner = raw
|
||||
.replace(/^\/\*\*/, '')
|
||||
.replace(/\*\/$/, '')
|
||||
.split('\n')
|
||||
.map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
|
||||
let hasMode = false
|
||||
let inTags = false
|
||||
const blocks: string[] = []
|
||||
let para: string[] = []
|
||||
let list: string[] = []
|
||||
let item: string[] = []
|
||||
const join = (parts: string[]): string => parts.join(' ').replace(/\s+/g, ' ').trim()
|
||||
const flushItem = (): void => {
|
||||
if (item.length) list.push(join(item))
|
||||
item = []
|
||||
}
|
||||
const flushList = (): void => {
|
||||
flushItem()
|
||||
if (list.length) blocks.push(list.join('\n')) // one block, items on own lines
|
||||
list = []
|
||||
}
|
||||
const flushPara = (): void => {
|
||||
flushList()
|
||||
if (para.length) blocks.push(join(para))
|
||||
para = []
|
||||
}
|
||||
for (const line of inner) {
|
||||
// Tag detection runs on the trimmed line: the normalization above strips at
|
||||
// most one post-`*` space, so an extra-indented `* @mode` still reaches
|
||||
// here with leading whitespace and must not leak into prose.
|
||||
const tagLine = line.trimStart()
|
||||
if (/^@mode\b/.test(tagLine)) { hasMode = true; flushPara(); inTags = true; continue }
|
||||
if (tagLine.startsWith('@')) { flushPara(); inTags = true; continue }
|
||||
if (inTags) continue // block-tag territory: continuations are never prose
|
||||
if (line.trim() === '') { flushPara(); continue }
|
||||
if (/^-\s+/.test(line)) {
|
||||
// A list item starts: a pending paragraph (e.g. an intro line directly
|
||||
// above the list, no blank between) flushes FIRST so it renders above.
|
||||
flushItem()
|
||||
if (para.length) { blocks.push(join(para)); para = [] }
|
||||
item.push(line)
|
||||
continue
|
||||
}
|
||||
if (item.length) { item.push(line); continue } // continuation of current item
|
||||
para.push(line)
|
||||
}
|
||||
flushPara()
|
||||
const doc = blocks.join('\n\n').replace(/\{@link\s+([^}]+)\}/g, '$1').trim()
|
||||
return { doc, hasMode }
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw one aggregate error for every completeness violation a walk collected.
|
||||
* Aggregation is deliberate: a remediation pass sees the whole list at once
|
||||
* instead of replaying the gate once per offender.
|
||||
*/
|
||||
function reportViolations(violations: string[]): void {
|
||||
if (violations.length === 0) return
|
||||
throw new Error(
|
||||
`gen-persistence-catalog: ${violations.length} JSDoc completeness violation(s):\n`
|
||||
+ violations.map(v => ` ${v}`).join('\n'),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `interface SessionEventMap` declaration in a source file: the owning
|
||||
* top-level declaration (in `@deepseek-ai/dsh-session`) and any declaration
|
||||
@@ -241,23 +109,15 @@ function packageNameFor(rel: string, scanRoot: string): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk every `SessionEventMap` declaration (the owning interface plus every
|
||||
* plugin declaration merge) and extract its events, hard-erroring (aggregated)
|
||||
* on any completeness violation: a member without description prose, an
|
||||
* `@mode` tag (a category error — log events have no dispatch mode), a member
|
||||
* that is not a property signature with an explicit payload type, a
|
||||
* non-literal member name, an `extends` clause (inherited keys would join
|
||||
* `keyof SessionEventMap` without a catalog row), a top-level declaration that
|
||||
* is not the single exported one in the owning package, or the same event
|
||||
* declared twice.
|
||||
* `scanRoot` defaults to the repo root; tests pass a fixture dir.
|
||||
* Collect every `SessionEventMap` merge, rejecting inherited, non-literal,
|
||||
* untyped, undocumented, duplicate, or incorrectly owned members in one report.
|
||||
*/
|
||||
export function collectLogEvents(scanRoot: string = root): LogEventEntry[] {
|
||||
const entries: LogEventEntry[] = []
|
||||
const violations: string[] = []
|
||||
const seen = new Map<string, string>()
|
||||
let owningDecl: string | null = null
|
||||
for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).sort()) {
|
||||
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 (!text.includes('SessionEventMap')) continue
|
||||
@@ -265,11 +125,9 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] {
|
||||
for (const { decl, topLevel } of sessionEventMapDecls(sf)) {
|
||||
const declSrc = pointer(rel, sf, decl)
|
||||
if (topLevel) {
|
||||
// The top-level form is the OWNING vocabulary, and it has exactly one
|
||||
// home: the single EXPORTED declaration in the owning package. A
|
||||
// same-named interface anywhere else — another package, a non-exported
|
||||
// local, a second exported copy — is a different type that must not be
|
||||
// catalogued as on-disk events.
|
||||
// The top-level form has one home: the single exported declaration in
|
||||
// the owning package. Same-named interfaces elsewhere are different
|
||||
// types and must not enter the on-disk catalog.
|
||||
const pkg = packageNameFor(rel, scanRoot)
|
||||
if (pkg !== SESSION_MODULE) {
|
||||
violations.push(`top-level interface SessionEventMap (${declSrc}) is outside ${SESSION_MODULE} (package ${pkg ?? 'unknown'}). Rename the interface, or contribute events via declare module '${SESSION_MODULE}'.`)
|
||||
@@ -323,7 +181,7 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] {
|
||||
}
|
||||
}
|
||||
}
|
||||
reportViolations(violations)
|
||||
reportViolations('gen-persistence-catalog', violations)
|
||||
return entries
|
||||
}
|
||||
|
||||
@@ -336,7 +194,7 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] {
|
||||
*/
|
||||
export function collectSurfaceEventTypes(scanRoot: string = root): string[] {
|
||||
const found: { names: string[]; source: string }[] = []
|
||||
for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).sort()) {
|
||||
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 (!text.includes('SurfaceEventType')) continue
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* Generate the dev-invariants scoped-event 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
|
||||
* every event payload parameter and one property level for exactly one type
|
||||
* equivalent to that key. Each generated resolver compiles against the merged
|
||||
* `Events` parameter tuple. Zero matches require `@dshScopeScan unsupported`;
|
||||
* multiple matches are ambiguous and always fail loud.
|
||||
*
|
||||
* `tsx scripts/gen-scoped-events.ts` -> write the generated source
|
||||
* `tsx scripts/gen-scoped-events.ts --check` -> exit 1 when it is stale
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
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 SCOPE_DOC_MARKER = 'Scope-filtered dispatch'
|
||||
|
||||
interface ScopeTargetContract {
|
||||
baseType: ts.Type
|
||||
keyType: ts.Type
|
||||
source: string
|
||||
}
|
||||
|
||||
interface SubjectCandidate {
|
||||
path: string
|
||||
parameter: number
|
||||
property?: string
|
||||
type: ts.Type
|
||||
}
|
||||
|
||||
interface ScopedEventResolver {
|
||||
event: string
|
||||
candidate: SubjectCandidate | null
|
||||
ownerPackage: string
|
||||
}
|
||||
|
||||
interface ScopeTag {
|
||||
present: boolean
|
||||
unsupported: boolean
|
||||
}
|
||||
|
||||
/** Program-backed analyzer and renderer for the generated scoped-event resolvers. */
|
||||
class ScopedEventGenerator {
|
||||
private readonly checker: ts.TypeChecker
|
||||
private readonly packageSources: ts.SourceFile[]
|
||||
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
|
||||
this.packageSources = project.sourceFiles().filter((sourceFile) => {
|
||||
return /^packages\/[^/]+\/[^/]+\/src\/.+\.ts$/.test(project.relativePath(sourceFile))
|
||||
})
|
||||
this.scopeTargetDeclaration = this.functionDeclaration(
|
||||
'packages/core/scope/src/index.ts',
|
||||
'scopeTarget',
|
||||
)
|
||||
this.scopedSymbol = this.typeAliasSymbol(
|
||||
'packages/core/scope/src/index.ts',
|
||||
'Scoped',
|
||||
)
|
||||
}
|
||||
|
||||
/** Render the complete generated TypeScript module or throw every contract violation. */
|
||||
render(): string {
|
||||
const contracts = this.collectScopeTargetContracts()
|
||||
const resolvers = this.collectScopedEventResolvers(contracts)
|
||||
if (this.violations.length > 0) {
|
||||
throw new Error(
|
||||
`gen-scoped-events: ${this.violations.length} scoped-event contract violation(s):\n`
|
||||
+ 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.',
|
||||
' * Do not edit by hand; run `pnpm run gen-scoped-events`.',
|
||||
' *',
|
||||
' * @module @deepseek-ai/dsh-invariants/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({',
|
||||
...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}),`
|
||||
}),
|
||||
'} 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',
|
||||
' * resolver means the payload cannot expose its external routing key, so the',
|
||||
' * invariant checks carrier presence only.',
|
||||
' * @param event - runtime Cordis event name.',
|
||||
' * @returns the generated subject resolver, null for presence-only,',
|
||||
' * or undefined when the event is not scope-filtered.',
|
||||
' */',
|
||||
'export function scopedSubjectResolverFor(event: string): ScopedSubjectResolver | null | undefined {',
|
||||
' return scopedSubjectResolverIndex[event]',
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Resolve one named function declaration from a known source file. */
|
||||
private functionDeclaration(relativePath: string, name: string): ts.FunctionDeclaration {
|
||||
const sourceFile = this.project.sourceFile(relativePath)
|
||||
const declaration = sourceFile.statements.find((statement): statement is ts.FunctionDeclaration => {
|
||||
return ts.isFunctionDeclaration(statement) && statement.name?.text === name
|
||||
})
|
||||
if (!declaration) throw new Error(`gen-scoped-events: cannot resolve function ${name} from ${relativePath}`)
|
||||
return declaration
|
||||
}
|
||||
|
||||
/** Resolve one named type-alias symbol from a known source file. */
|
||||
private typeAliasSymbol(relativePath: string, name: string): ts.Symbol {
|
||||
const sourceFile = this.project.sourceFile(relativePath)
|
||||
const declaration = sourceFile.statements.find((statement): statement is ts.TypeAliasDeclaration => {
|
||||
return ts.isTypeAliasDeclaration(statement) && statement.name.text === name
|
||||
})
|
||||
const symbol = declaration && this.checker.getSymbolAtLocation(declaration.name)
|
||||
if (!symbol) throw new Error(`gen-scoped-events: cannot resolve type ${name} from ${relativePath}`)
|
||||
return symbol
|
||||
}
|
||||
|
||||
/** Collect every real scopeTarget(base, key) base/key type contract. */
|
||||
private collectScopeTargetContracts(): ScopeTargetContract[] {
|
||||
const contracts: ScopeTargetContract[] = []
|
||||
const visit = (sourceFile: ts.SourceFile, node: ts.Node): void => {
|
||||
if (ts.isCallExpression(node)
|
||||
&& this.checker.getResolvedSignature(node)?.declaration === this.scopeTargetDeclaration) {
|
||||
const base = node.arguments[0]
|
||||
const key = node.arguments[1]
|
||||
if (!base || !key) {
|
||||
const source = pointer(this.project.relativePath(sourceFile), sourceFile, node)
|
||||
this.violations.push(`${source} calls scopeTarget without base and key arguments`)
|
||||
} else {
|
||||
contracts.push({
|
||||
baseType: this.checker.getTypeAtLocation(base),
|
||||
keyType: this.checker.getTypeAtLocation(key),
|
||||
source: pointer(this.project.relativePath(sourceFile), sourceFile, node),
|
||||
})
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, (child) => { visit(sourceFile, child) })
|
||||
}
|
||||
for (const sourceFile of this.packageSources) visit(sourceFile, sourceFile)
|
||||
return contracts
|
||||
}
|
||||
|
||||
/** Collect every Events member and derive its generated resolver. */
|
||||
private collectScopedEventResolvers(contracts: readonly ScopeTargetContract[]): ScopedEventResolver[] {
|
||||
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) {
|
||||
if (!ts.isMethodSignature(member) || !ts.isStringLiteral(member.name)) continue
|
||||
const event = member.name.text
|
||||
const raw = rawJsDoc(sourceFile.text, member)
|
||||
const where = `event '${event}' (${pointer(rel, sourceFile, member)})`
|
||||
const tag = parseScopeTag(raw, where, this.violations)
|
||||
const thisParameter = member.parameters.find(isThisParameter)
|
||||
const scopedBase = thisParameter && this.scopedBaseType(thisParameter)
|
||||
if (!scopedBase) {
|
||||
if (raw.includes(SCOPE_DOC_MARKER)) {
|
||||
this.violations.push(
|
||||
`${where} documents scope-filtered dispatch but its signature has no this: Scoped<...> receiver`,
|
||||
)
|
||||
}
|
||||
if (tag.present) {
|
||||
this.violations.push(`${where} has @dshScopeScan metadata but is not a Scoped event`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!raw.includes(SCOPE_DOC_MARKER)) {
|
||||
this.violations.push(
|
||||
`${where} has this: Scoped<...> but its JSDoc does not explain "${SCOPE_DOC_MARKER}"`,
|
||||
)
|
||||
}
|
||||
const keyType = this.routingKeyType(where, scopedBase, contracts)
|
||||
if (!keyType) continue
|
||||
const candidates = this.subjectCandidates(member)
|
||||
.filter(candidate => this.typesEquivalent(candidate.type, keyType))
|
||||
if (candidates.length > 1) {
|
||||
this.violations.push(
|
||||
`${where} has multiple routing-key candidates for ${this.typeText(keyType)}: `
|
||||
+ candidates.map(candidate => `${candidate.path}: ${this.typeText(candidate.type)}`).join(', '),
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
if (!tag.unsupported) {
|
||||
const keyLabel = this.typeText(keyType)
|
||||
this.violations.push(
|
||||
`${where} exposes no parameter or one-level property equivalent to routing key type ${keyLabel}; `
|
||||
+ 'add @dshScopeScan unsupported only when the key is intentionally absent from the payload',
|
||||
)
|
||||
}
|
||||
resolvers.push({ event, candidate: null, ownerPackage })
|
||||
continue
|
||||
}
|
||||
if (tag.unsupported) {
|
||||
this.violations.push(
|
||||
`${where} has unnecessary @dshScopeScan unsupported; ${candidates[0]?.path} exposes the routing key`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
resolvers.push({ event, candidate: candidates[0] ?? null, ownerPackage })
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(sourceFile)
|
||||
}
|
||||
return resolvers.sort((left, right) => left.event.localeCompare(right.event))
|
||||
}
|
||||
|
||||
/** Extract the Base type from one exact this: Scoped<Base> parameter. */
|
||||
private scopedBaseType(parameter: ts.ParameterDeclaration): ts.Type | undefined {
|
||||
const type = this.checker.getTypeAtLocation(parameter)
|
||||
if (type.aliasSymbol !== this.scopedSymbol) return undefined
|
||||
return type.aliasTypeArguments?.[0]
|
||||
}
|
||||
|
||||
/** Resolve one unambiguous key type for a scoped carrier base. */
|
||||
private routingKeyType(
|
||||
where: string,
|
||||
scopedBase: ts.Type,
|
||||
contracts: readonly ScopeTargetContract[],
|
||||
): ts.Type | undefined {
|
||||
const matches = contracts.filter((contract) => {
|
||||
return this.checker.isTypeAssignableTo(this.normalizedType(contract.baseType), this.normalizedType(scopedBase))
|
||||
})
|
||||
if (matches.length === 0) {
|
||||
this.violations.push(
|
||||
`${where} has no matching scopeTarget(base, key) call for carrier base ${this.typeText(scopedBase)}`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
const keyTypes: ts.Type[] = []
|
||||
for (const match of matches) {
|
||||
if (!keyTypes.some(type => this.typesEquivalent(type, match.keyType))) keyTypes.push(match.keyType)
|
||||
}
|
||||
if (keyTypes.length > 1) {
|
||||
this.violations.push(
|
||||
`${where} carrier base ${this.typeText(scopedBase)} has inconsistent routing-key types: `
|
||||
+ matches.map(match => `${this.typeText(match.keyType)} at ${match.source}`).join(', '),
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
return keyTypes[0]
|
||||
}
|
||||
|
||||
/** Enumerate every payload parameter and every accessible one-level property. */
|
||||
private subjectCandidates(member: ts.MethodSignature): SubjectCandidate[] {
|
||||
const candidates: SubjectCandidate[] = []
|
||||
let runtimeIndex = 0
|
||||
for (const parameter of member.parameters) {
|
||||
if (isThisParameter(parameter)) continue
|
||||
const directPath = `args[${runtimeIndex}]`
|
||||
const parameterType = this.checker.getTypeAtLocation(parameter)
|
||||
candidates.push({ path: directPath, parameter: runtimeIndex, type: parameterType })
|
||||
for (const property of this.checker.getPropertiesOfType(this.normalizedType(parameterType))) {
|
||||
const name = property.getName()
|
||||
if (name.startsWith('__@') || hasNonPublicDeclaration(property)) continue
|
||||
candidates.push({
|
||||
path: `${directPath}.${name}`,
|
||||
parameter: runtimeIndex,
|
||||
property: name,
|
||||
type: this.checker.getTypeOfSymbolAtLocation(property, parameter),
|
||||
})
|
||||
}
|
||||
runtimeIndex += 1
|
||||
}
|
||||
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)
|
||||
const normalizedRight = this.normalizedType(right)
|
||||
if (normalizedLeft.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) return false
|
||||
if (normalizedRight.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) return false
|
||||
return normalizedLeft === normalizedRight
|
||||
}
|
||||
|
||||
/** Remove null and undefined from a routing or candidate type. */
|
||||
private normalizedType(type: ts.Type): ts.Type {
|
||||
return this.checker.getNonNullableType(type)
|
||||
}
|
||||
|
||||
/** Render a stable diagnostic type label. */
|
||||
private typeText(type: ts.Type): string {
|
||||
return this.checker.typeToString(type, undefined, ts.TypeFormatFlags.NoTruncation)
|
||||
}
|
||||
}
|
||||
|
||||
/** Return whether an Events interface is inside declare module 'cordis'. */
|
||||
function isCordisModuleInterface(node: ts.InterfaceDeclaration): boolean {
|
||||
const block = node.parent
|
||||
const declaration = block.parent
|
||||
return ts.isModuleBlock(block)
|
||||
&& ts.isModuleDeclaration(declaration)
|
||||
&& ts.isStringLiteral(declaration.name)
|
||||
&& declaration.name.text === 'cordis'
|
||||
}
|
||||
|
||||
/** Return whether a parameter is the explicit TypeScript this receiver. */
|
||||
function isThisParameter(parameter: ts.ParameterDeclaration): boolean {
|
||||
return ts.isIdentifier(parameter.name) && parameter.name.text === 'this'
|
||||
}
|
||||
|
||||
/** Parse and validate the optional @dshScopeScan unsupported tag. */
|
||||
function parseScopeTag(raw: string, where: string, violations: string[]): ScopeTag {
|
||||
const tags = raw
|
||||
.replace(/^\/\*\*/, '')
|
||||
.replace(/\*\/$/, '')
|
||||
.split('\n')
|
||||
.map(line => line.replace(/^\s*\*?\s?/, '').trim())
|
||||
.filter(line => line.startsWith('@dshScopeScan'))
|
||||
if (tags.length > 1) violations.push(`${where} has multiple @dshScopeScan tags`)
|
||||
if (tags.length === 0) return { present: false, unsupported: false }
|
||||
const unsupported = tags[0] === '@dshScopeScan unsupported'
|
||||
if (!unsupported) {
|
||||
violations.push(
|
||||
`${where} has invalid scoped-event scan metadata '${tags[0]}'; expected '@dshScopeScan unsupported'`,
|
||||
)
|
||||
}
|
||||
return { present: true, unsupported }
|
||||
}
|
||||
|
||||
/** Return whether a property has a private or protected declaration. */
|
||||
function hasNonPublicDeclaration(symbol: ts.Symbol): boolean {
|
||||
return (symbol.declarations ?? []).some((declaration) => {
|
||||
if (!ts.canHaveModifiers(declaration)) return false
|
||||
return ts.getModifiers(declaration)?.some((modifier) => {
|
||||
return modifier.kind === ts.SyntaxKind.PrivateKeyword || modifier.kind === ts.SyntaxKind.ProtectedKeyword
|
||||
}) ?? false
|
||||
})
|
||||
}
|
||||
|
||||
/** Deduplicate candidate paths contributed by merged/intersection types. */
|
||||
function dedupeCandidates(candidates: readonly SubjectCandidate[]): SubjectCandidate[] {
|
||||
const seen = new Set<string>()
|
||||
return candidates.filter((candidate) => {
|
||||
if (seen.has(candidate.path)) return false
|
||||
seen.add(candidate.path)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/** 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("'", "\\'")}'`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the generated scoped-event resolver module for one repository root.
|
||||
* @param projectRoot - repository root carrying tsconfig.json.
|
||||
* @returns complete generated TypeScript source.
|
||||
*/
|
||||
export function renderScopedEvents(projectRoot: string = root): string {
|
||||
return new ScopedEventGenerator(new TypeScriptProject(projectRoot)).render()
|
||||
}
|
||||
|
||||
/** Generate or freshness-check the fixed invariants source file. */
|
||||
function main(): void {
|
||||
const content = renderScopedEvents()
|
||||
const output = resolve(root, OUT)
|
||||
if (process.argv.includes('--check')) {
|
||||
const committed = existsSync(output) ? readFileSync(output, 'utf8') : null
|
||||
if (committed === content) {
|
||||
console.log(`gen-scoped-events: ${OUT} is up to date.`)
|
||||
return
|
||||
}
|
||||
console.error(`gen-scoped-events: ${OUT} is stale. Run \`pnpm run gen-scoped-events\` and commit it.`)
|
||||
process.exit(1)
|
||||
}
|
||||
writeFileSync(output, content)
|
||||
console.log(`gen-scoped-events: wrote ${OUT}.`)
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
|
||||
main()
|
||||
}
|
||||
+49
-54
@@ -1,36 +1,9 @@
|
||||
/**
|
||||
* Generate (and verify) the tool-schema catalog in docs/tool-catalog.md.
|
||||
*
|
||||
* The catalog is the MODEL-FACING TOOL reference: every tool a shipped plugin
|
||||
* contributes to `ctx.tools`, with the exact `name` / `description` / JSON-Schema
|
||||
* `parameters` the model receives via the system-prompt assembly. It complements
|
||||
* the cordis events/services catalog (the wiring a plugin author works against)
|
||||
* and the core-data-structures catalog (the vocabulary those signatures move):
|
||||
* this page is the TOOLS the agent is offered.
|
||||
*
|
||||
* `tsx scripts/gen-tool-catalog.ts` → write the catalog
|
||||
* `tsx scripts/gen-tool-catalog.ts --check` → exit 1 if the committed file
|
||||
* is stale (CI / pre-push gate)
|
||||
*
|
||||
* Why this generator BOOTS PLUGINS instead of parsing source (unlike its AST
|
||||
* sibling `gen-cordis-catalog.ts`): a tool's schema is not statically knowable.
|
||||
* `tool-todo` writes `enum: [...STATUSES]` (a runtime spread), descriptions are
|
||||
* built by string concatenation, `tool-subagent`'s tool name is `config.toolName`,
|
||||
* and an MCP plugin can register RAW JSON Schema without `defineTool` at all. The
|
||||
* faithful source of truth is therefore the SHIPPED schema: mount each tool
|
||||
* plugin on a real cordis Context and read `ctx.tools.schemas()` — exactly the
|
||||
* `ToolSchema[]` the model is sent. See
|
||||
* docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md.
|
||||
*
|
||||
* Booting sacrifices the AST pass's structural "nothing can be silently omitted"
|
||||
* property (there is no source declaration to enumerate), so a COMPLETENESS GUARD
|
||||
* restores it: the generator globs every `tool-*` package under `packages/` and
|
||||
* hard-errors if any such package is absent from the boot manifest below. A new
|
||||
* tool package fails the generator — and thus the freshness gate — until it is
|
||||
* registered here, mirroring how a new event appears in the cordis regenerate.
|
||||
*
|
||||
* Schema blocks use a plain ` ```json ` fence: doc-typecheck only extracts `ts*`
|
||||
* fences, so no BlockKind wiring is needed there.
|
||||
* Generate `docs/tool-catalog.md` from schemas collected by booting each tool
|
||||
* 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`.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
@@ -47,10 +20,15 @@ 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 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 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'
|
||||
@@ -61,17 +39,9 @@ const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'docs/tool-catalog.md'
|
||||
|
||||
/**
|
||||
* One tool-plugin package to boot. `mount` is a per-entry recipe (async): it
|
||||
* plugs the injected seams the plugin's `apply` reads (an executor for
|
||||
* `ctx.bash`, a provider for `ctx.subagents`) BEFORE the tool plugin itself.
|
||||
* `SystemPrompt` + `ToolRegistry` are mounted for every entry by the caller
|
||||
* (`ToolRegistry` injects `systemPrompt`), so `mount` only handles the extras.
|
||||
*
|
||||
* The recipe is irreducible policy — WHICH seams a given tool needs and with
|
||||
* WHAT config is not derivable from the package layout — so it stays a hand-
|
||||
* maintained closure. The `dir` field is what the completeness guard matches
|
||||
* against the on-disk `tool-*` package glob, so a NEW tool package cannot be
|
||||
* silently omitted (see the module doc).
|
||||
* Tool package plus its hand-maintained boot recipe. The caller mounts the
|
||||
* prompt and registry; each recipe supplies only package-specific seams and
|
||||
* config, while `dir` participates in the completeness check.
|
||||
*/
|
||||
interface ToolPackage {
|
||||
/** The npm package name, used as the catalog section heading. */
|
||||
@@ -137,20 +107,20 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
toolsConfig: { mode: 'code' },
|
||||
async mount() {},
|
||||
note:
|
||||
'Registered by the tool registry itself under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the ONLY wire tool; the other registered tools are declared to the model as a generated TypeScript SDK prompt section instead, and a program calls them through port-bridged bindings that dispatch through the ordinary tools/pre-execute → tools/post-execute pipeline, one at a time.',
|
||||
'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.',
|
||||
},
|
||||
{
|
||||
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',
|
||||
@@ -171,15 +141,29 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
requires: ['ctx.tools', 'ctx.fs', 'ctx.systemPrompt'],
|
||||
writes: ['tool/call', 'fs/write-intent or fs/edit-intent for mutations', 'fs/observed after successful file operations', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
// The tool injects `fs`; boot the local backend to satisfy it. The schemas
|
||||
// do not depend on the policy plugin (an event gate that changes behavior,
|
||||
// not tool shape), so the bare provider is enough to harvest them.
|
||||
// The tool needs `fs`; the bare provider is sufficient because policy
|
||||
// changes behavior, not schema shape.
|
||||
await ctx.plugin(LocalFileSystem)
|
||||
await ctx.plugin(ToolFs)
|
||||
},
|
||||
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-skill',
|
||||
dir: 'tool-skill',
|
||||
source: 'packages/skill/tool-skill/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.skills'],
|
||||
writes: ['tool/call', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(SkillService)
|
||||
await ctx.plugin(SkillLocal, {
|
||||
dshHome: resolve(root, '.tmp/tool-catalog/.dsh'),
|
||||
agentsHome: resolve(root, '.tmp/tool-catalog/.agents'),
|
||||
})
|
||||
await ctx.plugin(ToolSkill)
|
||||
},
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-subagent',
|
||||
dir: 'tool-subagent',
|
||||
@@ -196,6 +180,19 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
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`.',
|
||||
},
|
||||
{
|
||||
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',
|
||||
dir: 'tool-todo',
|
||||
@@ -231,10 +228,8 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
requires: ['ctx.tools', 'ctx.web', 'ctx.systemPrompt'],
|
||||
writes: ['tool/call', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
// The tools inject `web`; boot the seam plus one search and one fetch
|
||||
// provider so both `web_search` and `web_fetch` register. The schemas do
|
||||
// not depend on which provider backs the seam (or on it being available),
|
||||
// so any registered provider is enough to harvest them.
|
||||
// Mount search and fetch providers so both tools register. Their schemas
|
||||
// do not depend on provider identity or availability.
|
||||
await ctx.plugin(WebService)
|
||||
await ctx.plugin(WebSearchExa)
|
||||
await ctx.plugin(WebFetchLocal)
|
||||
|
||||
@@ -6,8 +6,16 @@ import { join } from 'node:path'
|
||||
const git = spawnSync('git', ['rev-parse', '--git-dir'], { stdio: 'ignore' })
|
||||
if (git.status !== 0) process.exit(0)
|
||||
|
||||
const lefthook = join(process.cwd(), 'node_modules', '.bin', process.platform === 'win32' ? 'lefthook.cmd' : 'lefthook')
|
||||
const isWindows = process.platform === 'win32'
|
||||
const lefthook = join(process.cwd(), 'node_modules', '.bin', isWindows ? 'lefthook.cmd' : 'lefthook')
|
||||
if (!existsSync(lefthook)) process.exit(0)
|
||||
|
||||
const result = spawnSync(lefthook, ['install', '--force'], { stdio: 'inherit' })
|
||||
// On Windows the bin shim is a `.cmd` file, and recent Node (CVE-2024-27980)
|
||||
// refuses to launch `.cmd`/`.bat` via spawn without `shell: true` — it returns
|
||||
// `EINVAL` with a null status, which would otherwise fail postinstall. Quote
|
||||
// the path because a shell re-parses the command line and the path may contain
|
||||
// spaces. POSIX needs no shell: the extensionless shim is directly executable.
|
||||
const result = isWindows
|
||||
? spawnSync(`"${lefthook}"`, ['install', '--force'], { stdio: 'inherit', shell: true })
|
||||
: spawnSync(lefthook, ['install', '--force'], { stdio: 'inherit' })
|
||||
process.exit(result.status ?? 1)
|
||||
+27
-44
@@ -1,14 +1,6 @@
|
||||
/**
|
||||
* Shared JSDoc parsing and completeness-check helpers for the documentation
|
||||
* gates: the cordis catalog generator (`scripts/gen-cordis-catalog.ts` — the
|
||||
* events + `ctx.<key>` service surface), the plugin config catalog generator
|
||||
* (`scripts/gen-config-catalog.ts`, which renders the parsed prose), and the
|
||||
* export-surface gate (`scripts/verify-export-jsdoc.ts` — every module-level
|
||||
* export). One home for the mechanics so "documented" means the same thing on
|
||||
* every gated surface: description prose ends at the first block tag; every
|
||||
* checkable parameter needs a non-empty `@param`; a non-void ANNOTATED return
|
||||
* needs a non-empty `@returns`; a stale `@param` naming no real parameter
|
||||
* errors.
|
||||
* Shared JSDoc parsing and completeness checks for the Cordis, persistence,
|
||||
* and config catalogs and the export-surface gate.
|
||||
*/
|
||||
|
||||
import ts from 'typescript'
|
||||
@@ -30,24 +22,21 @@ export function rawJsDoc(text: string, node: ts.Node): string {
|
||||
export type Mode = 'emit' | 'waterfall' | 'parallel' | 'serial'
|
||||
|
||||
/**
|
||||
* Parse a raw JSDoc block into description prose + the `@mode` tag (when
|
||||
* present). Output obeys the repo's markdown conventions so the generated
|
||||
* catalog passes verify-md-wrap: each prose paragraph collapses to ONE physical
|
||||
* line, and a `-` bullet list is preserved with each item on its own single
|
||||
* line (continuation lines folded in). `{@link Foo}` unwraps to `Foo`.
|
||||
* Description prose ends at the FIRST block tag (standard JSDoc semantics):
|
||||
* tag lines and their continuation lines are never prose, so `@param` /
|
||||
* `@returns` blocks are invisible to the rendered catalog.
|
||||
* Parse a raw JSDoc block into description prose and an optional `@mode`. Prose
|
||||
* ends at the first block tag, paragraphs collapse to one line, bullet items
|
||||
* remain separate lines, and `{@link X}` renders as `X`.
|
||||
* @param raw - the raw comment text including the JSDoc delimiters.
|
||||
* @returns the collapsed description prose plus the parsed `@mode` (or null).
|
||||
* @returns the collapsed description prose, parsed valid `@mode` (or null),
|
||||
* and whether any `@mode` tag was present.
|
||||
*/
|
||||
export function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
|
||||
export function parseJsDoc(raw: string): { doc: string; mode: Mode | null; hasMode: boolean } {
|
||||
const inner = raw
|
||||
.replace(/^\/\*\*/, '')
|
||||
.replace(/\*\/$/, '')
|
||||
.split('\n')
|
||||
.map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
|
||||
let mode: Mode | null = null
|
||||
let hasMode = false
|
||||
let inTags = false
|
||||
const blocks: string[] = []
|
||||
let para: string[] = []
|
||||
@@ -69,9 +58,11 @@ export function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
|
||||
para = []
|
||||
}
|
||||
for (const line of inner) {
|
||||
const m = /^@mode\s+(emit|waterfall|parallel|serial)\s*$/.exec(line)
|
||||
if (m) { mode = m[1] as Mode; flushPara(); inTags = true; continue }
|
||||
if (line.startsWith('@')) { flushPara(); inTags = true; continue }
|
||||
const tagLine = line.trimStart()
|
||||
const m = /^@mode\s+(emit|waterfall|parallel|serial)\s*$/.exec(tagLine)
|
||||
if (m) { mode = m[1] as Mode; hasMode = true; flushPara(); inTags = true; continue }
|
||||
if (/^@mode\b/.test(tagLine)) { hasMode = true; flushPara(); inTags = true; continue }
|
||||
if (tagLine.startsWith('@')) { flushPara(); inTags = true; continue }
|
||||
if (inTags) continue // block-tag territory: continuations are never prose
|
||||
if (line.trim() === '') { flushPara(); continue }
|
||||
if (/^-\s+/.test(line)) {
|
||||
@@ -87,17 +78,12 @@ export function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
|
||||
}
|
||||
flushPara()
|
||||
const doc = blocks.join('\n\n').replace(/\{@link\s+([^}]+)\}/g, '$1').trim()
|
||||
return { doc, mode }
|
||||
return { doc, mode, hasMode }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the block tags of a raw JSDoc comment for the completeness checks:
|
||||
* every `@param name — description` entry plus the `@returns` description.
|
||||
* Standard JSDoc block-tag semantics — a tag's description runs across
|
||||
* continuation lines until the next tag or a blank line, and the `-`/`—`
|
||||
* separator after a param name is optional. `[name]` optional-brackets unwrap
|
||||
* to `name`. Rendering never sees these: parseJsDoc stops prose at the first
|
||||
* block tag.
|
||||
* Parse `@param` and `@returns` descriptions, including continuation lines.
|
||||
* Parameter separators are optional and `[optional]` names unwrap.
|
||||
* @param raw - the raw comment text including the JSDoc delimiters.
|
||||
* @returns the `@param` name→description map plus the `@returns` description
|
||||
* (null when the tag is absent, '' when present but empty).
|
||||
@@ -134,17 +120,15 @@ export function parseTags(raw: string): { params: Map<string, string>; returns:
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the `@param` half of the completeness contract for one function-like
|
||||
* declaration: every checkable parameter carries a non-empty `@param`, and no
|
||||
* `@param` is stale. A binding-pattern parameter is a violation (it has no name
|
||||
* for `@param` to match); an exempt parameter may be documented but its absence
|
||||
* is never checked. Violations append to `violations` in place.
|
||||
* Require a non-empty tag for each non-exempt identifier parameter, reject
|
||||
* binding-pattern parameters, and reject stale tags. Exempt parameters may
|
||||
* still be documented.
|
||||
* @param where - the offender label violations open with, e.g. `event 'x' (file:1)`.
|
||||
* @param surface - the surface noun for the binding-pattern message ("event", "service", "export").
|
||||
* @param surface - surface noun used in binding-pattern diagnostics.
|
||||
* @param parameters - the declaration's parameter list.
|
||||
* @param tags - the parsed `@param` name→description map from parseTags.
|
||||
* @param sf - the source file (for rendering a binding pattern's text).
|
||||
* @param isExempt - which parameters need no `@param` (e.g. `this`, a waterfall's trailing `next`).
|
||||
* @param sf - source file used to render binding patterns.
|
||||
* @param isExempt - parameters whose tag is optional, such as `this` or waterfall `next`.
|
||||
* @param violations - the aggregate list violations append to.
|
||||
*/
|
||||
export function checkParams(
|
||||
@@ -174,11 +158,10 @@ export function checkParams(
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the `@returns` half of the completeness contract: a non-`void` /
|
||||
* `Promise<void>` return needs a non-empty `@returns`, and the return type must
|
||||
* be ANNOTATED — a pure-AST walk cannot classify an inferred return. On a void
|
||||
* declaration `@returns` stays optional (resolution timing can be worth
|
||||
* documenting), never required. Violations append to `violations` in place.
|
||||
* Check the `@returns` half of the completeness contract: a non-`void` / `Promise<void>`
|
||||
* return needs a non-empty `@returns`, and the return type must be ANNOTATED — a pure-AST
|
||||
* walk cannot classify an inferred return. Void returns may still carry an
|
||||
* optional tag, for example to document resolution timing.
|
||||
* @param where - the offender label violations open with.
|
||||
* @param typeNode - the declared return type annotation, or undefined when inferred.
|
||||
* @param returns - the parsed `@returns` description from parseTags (null when absent).
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/** Shared Markdown parsing and depth-first traversal for documentation gates. */
|
||||
|
||||
import { fromMarkdown } from 'mdast-util-from-markdown'
|
||||
import { gfmFromMarkdown } from 'mdast-util-gfm'
|
||||
import { gfm } from 'micromark-extension-gfm'
|
||||
import type { Nodes } from 'mdast'
|
||||
|
||||
/** One authored Markdown line outside fenced code and rendered-away HTML comments. */
|
||||
export interface MarkdownProseLine {
|
||||
/** 1-based source line number. */
|
||||
index: number
|
||||
/** Source text without normalization. */
|
||||
raw: string
|
||||
}
|
||||
|
||||
/** One parsed Markdown heading, retaining its authored first line and rendered text. */
|
||||
export interface MarkdownHeadingLine extends MarkdownProseLine {
|
||||
/** Parsed ATX or Setext heading depth. */
|
||||
depth: 1 | 2 | 3 | 4 | 5 | 6
|
||||
/** Rendered heading text, excluding raw HTML such as comments. */
|
||||
text: string
|
||||
}
|
||||
|
||||
/** Parse GitHub-flavored Markdown with the repository's standard extensions. */
|
||||
export function parseMarkdown(source: string): Nodes {
|
||||
return fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit a Markdown tree depth-first; returning false prunes a node's children.
|
||||
* @param node - current tree node.
|
||||
* @param visitor - callback invoked before each node's children.
|
||||
*/
|
||||
export function visitMarkdown(node: Nodes, visitor: (node: Nodes) => boolean | void): void {
|
||||
if (visitor(node) === false) return
|
||||
if ('children' in node) {
|
||||
for (const child of node.children) visitMarkdown(child, visitor)
|
||||
}
|
||||
}
|
||||
|
||||
/** Text a reader sees from one Markdown node; raw HTML itself contributes none. */
|
||||
function renderedText(node: Nodes): string {
|
||||
if (node.type === 'text' || node.type === 'inlineCode') return node.value
|
||||
if (node.type === 'image' || node.type === 'imageReference') return node.alt ?? ''
|
||||
if (node.type === 'break') return ' '
|
||||
if ('children' in node) return node.children.map(child => renderedText(child)).join('')
|
||||
return ''
|
||||
}
|
||||
|
||||
/** Return every parsed Markdown heading with its rendered text and source line. */
|
||||
export function markdownHeadingLines(source: string): MarkdownHeadingLine[] {
|
||||
const rawLines = source.split('\n')
|
||||
const headings: MarkdownHeadingLine[] = []
|
||||
visitMarkdown(parseMarkdown(source), (node) => {
|
||||
if (node.type !== 'heading' || node.position === undefined) return
|
||||
headings.push({
|
||||
depth: node.depth,
|
||||
index: node.position.start.line,
|
||||
raw: rawLines[node.position.start.line - 1] ?? '',
|
||||
text: renderedText(node),
|
||||
})
|
||||
})
|
||||
return headings
|
||||
}
|
||||
|
||||
type ColumnRange = readonly [start: number, end: number]
|
||||
type OffsetRange = readonly [start: number, end: number]
|
||||
|
||||
/** Source-column ranges occupied by parsed HTML comments, keyed by source line. */
|
||||
function htmlCommentRanges(source: string, rawLines: readonly string[]): Map<number, ColumnRange[]> {
|
||||
const comments: OffsetRange[] = []
|
||||
visitMarkdown(parseMarkdown(source), (node) => {
|
||||
if (node.type !== 'html' || node.position?.start.offset === undefined) return
|
||||
let cursor = 0
|
||||
while (true) {
|
||||
const start = node.value.indexOf('<!--', cursor)
|
||||
if (start < 0) break
|
||||
const close = node.value.indexOf('-->', start + '<!--'.length)
|
||||
const end = close < 0 ? node.value.length : close + '-->'.length
|
||||
comments.push([node.position.start.offset + start, node.position.start.offset + end])
|
||||
cursor = end
|
||||
}
|
||||
})
|
||||
|
||||
const ranges = new Map<number, ColumnRange[]>()
|
||||
let lineOffset = 0
|
||||
rawLines.forEach((raw, index) => {
|
||||
const lineEnd = lineOffset + raw.length
|
||||
for (const [start, end] of comments) {
|
||||
const from = Math.max(start, lineOffset)
|
||||
const to = Math.min(end, lineEnd)
|
||||
const coversEmptyLine = raw.length === 0 && start <= lineOffset && end > lineOffset
|
||||
if (from < to || coversEmptyLine) {
|
||||
const lineRanges = ranges.get(index + 1) ?? []
|
||||
lineRanges.push([from - lineOffset, to - lineOffset])
|
||||
ranges.set(index + 1, lineRanges)
|
||||
}
|
||||
}
|
||||
lineOffset = lineEnd + 1
|
||||
})
|
||||
return ranges
|
||||
}
|
||||
|
||||
/** Whether a source line retains non-whitespace text after HTML comments disappear. */
|
||||
function hasRenderedTextOutsideComments(raw: string, ranges: readonly ColumnRange[] | undefined): boolean {
|
||||
if (ranges === undefined) return true
|
||||
let cursor = 0
|
||||
let visible = ''
|
||||
for (const [start, end] of [...ranges].sort((left, right) => left[0] - right[0])) {
|
||||
visible += raw.slice(cursor, start)
|
||||
cursor = Math.max(cursor, end)
|
||||
}
|
||||
visible += raw.slice(cursor)
|
||||
return visible.trim().length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Return source lines outside backtick or tilde fences and HTML comments.
|
||||
* @param source - Markdown source whose prose should be retained verbatim.
|
||||
* @returns unfenced lines with their original 1-based locations.
|
||||
*/
|
||||
export function markdownProseLines(source: string): MarkdownProseLine[] {
|
||||
let fence: { marker: '`' | '~'; length: number } | undefined
|
||||
const kept: MarkdownProseLine[] = []
|
||||
const rawLines = source.split('\n')
|
||||
const comments = htmlCommentRanges(source, rawLines)
|
||||
rawLines.forEach((raw, i) => {
|
||||
const token = /^ {0,3}(`{3,}|~{3,})/.exec(raw)?.[1]
|
||||
if (token !== undefined) {
|
||||
const marker = token[0] as '`' | '~'
|
||||
if (fence === undefined) {
|
||||
fence = { marker, length: token.length }
|
||||
} else if (marker === fence.marker && token.length >= fence.length) {
|
||||
fence = undefined
|
||||
}
|
||||
return
|
||||
}
|
||||
if (fence === undefined && hasRenderedTextOutsideComments(raw, comments.get(i + 1))) {
|
||||
kept.push({ index: i + 1, raw })
|
||||
}
|
||||
})
|
||||
return kept
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Shared workspace-package graph discovery and Mermaid identifier helpers for
|
||||
* the generated module graph and relationship-diagram generators. Each caller
|
||||
* supplies its own group ordering because the documents use different visual
|
||||
* priorities; manifest parsing and dependency-safe ordering have one owner.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { dirname, resolve, sep } from 'node:path'
|
||||
|
||||
const SCOPE = '@deepseek-ai/dsh-'
|
||||
|
||||
/** One harness package and its in-repo peer-dependency edges. */
|
||||
export interface PackageGraphNode {
|
||||
/** Package name with the `@deepseek-ai/dsh-` prefix removed. */
|
||||
short: string
|
||||
/** Full npm package name. */
|
||||
name: string
|
||||
/** Package group from `packages/<group>/<pkg>`. */
|
||||
group: string
|
||||
/** Repo-relative package directory. */
|
||||
rel: string
|
||||
/** Short names of in-repo peer dependencies, sorted. */
|
||||
deps: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every harness package manifest and return dependency-safe graph nodes.
|
||||
* @param root - absolute repository root.
|
||||
* @param groupOrder - caller-specific tiebreak order for packages in the same dependency layer.
|
||||
* @param gate - command name used in structural error messages.
|
||||
* @returns package nodes ordered after all of their in-repo dependencies.
|
||||
*/
|
||||
export function collectPackageGraph(root: string, groupOrder: readonly string[], gate: string): PackageGraphNode[] {
|
||||
const packages: PackageGraphNode[] = []
|
||||
for (const rel of globSync('packages/*/*/package.json', { cwd: root }).map(path => path.split(sep).join('/')).sort()) {
|
||||
const json = JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as {
|
||||
name: string
|
||||
peerDependencies?: Record<string, string>
|
||||
}
|
||||
if (!json.name.startsWith(SCOPE)) continue
|
||||
const [, group, leaf] = rel.split('/')
|
||||
if (group === undefined || leaf === undefined) throw new Error(`${gate}: unexpected package path ${rel}`)
|
||||
const deps = Object.keys(json.peerDependencies ?? {})
|
||||
.filter(dep => dep.startsWith(SCOPE))
|
||||
.map(dep => dep.slice(SCOPE.length))
|
||||
.sort()
|
||||
packages.push({
|
||||
short: json.name.slice(SCOPE.length),
|
||||
name: json.name,
|
||||
group,
|
||||
rel: dirname(rel),
|
||||
deps,
|
||||
})
|
||||
}
|
||||
return topoSort(packages, groupOrder, gate)
|
||||
}
|
||||
|
||||
function topoSort(packages: PackageGraphNode[], groupOrder: readonly string[], gate: string): PackageGraphNode[] {
|
||||
const remaining = new Map(packages.map(pkg => [pkg.short, pkg]))
|
||||
const placed = new Set<string>()
|
||||
const out: PackageGraphNode[] = []
|
||||
while (remaining.size > 0) {
|
||||
const ready = [...remaining.values()]
|
||||
.filter(pkg => pkg.deps.every(dep => placed.has(dep)))
|
||||
.sort((a, b) => comparePackages(a, b, groupOrder))
|
||||
if (ready.length === 0) throw new Error(`${gate}: dependency cycle among ${[...remaining.keys()].join(', ')}`)
|
||||
for (const pkg of ready) {
|
||||
out.push(pkg)
|
||||
placed.add(pkg.short)
|
||||
remaining.delete(pkg.short)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function comparePackages(a: PackageGraphNode, b: PackageGraphNode, groupOrder: readonly string[]): number {
|
||||
const groupA = groupOrder.indexOf(a.group)
|
||||
const groupB = groupOrder.indexOf(b.group)
|
||||
const normA = groupA === -1 ? Number.MAX_SAFE_INTEGER : groupA
|
||||
const normB = groupB === -1 ? Number.MAX_SAFE_INTEGER : groupB
|
||||
return normA - normB || a.group.localeCompare(b.group) || a.short.localeCompare(b.short)
|
||||
}
|
||||
|
||||
/** Stable Mermaid id for a graph value. */
|
||||
export function graphNodeId(prefix: string, value: string): string {
|
||||
return `${prefix}_${value.replace(/[^a-zA-Z0-9_]/g, '_')}`
|
||||
}
|
||||
|
||||
/** Escape a value embedded in a quoted Mermaid label. */
|
||||
export function escapeMermaidLabel(value: string): string {
|
||||
return value.replace(/"/g, '\\"')
|
||||
}
|
||||
+10
-5
@@ -7,13 +7,18 @@ import { promisify } from 'node:util'
|
||||
const execFileAsync = promisify(execFile)
|
||||
const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY'
|
||||
|
||||
// publint every harness package. Packages live at packages/<group>/<pkg>
|
||||
// (the group dirs — core/llm/bash/… — are pure containers); vendor/ is private
|
||||
// upstream code and examples/ are not packages, both out of scope. Derived
|
||||
// from the hierarchy so a new package needs no edit here.
|
||||
// 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')
|
||||
|
||||
// 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')
|
||||
|
||||
type PublintResult =
|
||||
| { path: string; status: 'passed'; stdout: string; stderr: string }
|
||||
| { path: string; status: 'failed'; stdout: string; stderr: string; message: string }
|
||||
@@ -52,7 +57,7 @@ function outputText(value: unknown): string {
|
||||
|
||||
async function runPublint(path: string): Promise<PublintResult> {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync('node_modules/.bin/publint', [path], {
|
||||
const { stdout, stderr } = await execFileAsync(process.execPath, [publintCli, path], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/** Shared repository file discovery and line-oriented reference scanning. */
|
||||
|
||||
import { globSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { relative, resolve, sep } from 'node:path'
|
||||
|
||||
/** One authored path plus its canonical target for symlink deduplication. */
|
||||
export interface RepoFile {
|
||||
/** Absolute path matched by the caller's glob. */
|
||||
abs: string
|
||||
/** Absolute canonical path used only for deduplication. */
|
||||
real: string
|
||||
}
|
||||
|
||||
/** A rejected line-oriented repository reference. */
|
||||
export interface ReferenceViolation {
|
||||
/** Repo-relative file containing the reference. */
|
||||
file: string
|
||||
/** 1-based line containing the reference. */
|
||||
line: number
|
||||
/** Normalized reference text. */
|
||||
ref: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand repository-relative globs and deduplicate symlinked files.
|
||||
* @param root - absolute repository root.
|
||||
* @param patterns - repository-relative glob patterns, processed in order.
|
||||
* @param isExcluded - optional predicate over each matched relative path.
|
||||
* @returns matched files in stable first-seen order.
|
||||
*/
|
||||
export function uniqueRepoFiles(
|
||||
root: string,
|
||||
patterns: readonly string[],
|
||||
isExcluded: (relativePath: string) => boolean = () => false,
|
||||
): RepoFile[] {
|
||||
const seen = new Set<string>()
|
||||
const files: RepoFile[] = []
|
||||
for (const pattern of patterns) {
|
||||
for (const match of globSync(pattern, { cwd: root })) {
|
||||
const repoPath = match.split(sep).join('/')
|
||||
if (isExcluded(repoPath)) continue
|
||||
const abs = resolve(root, repoPath)
|
||||
const real = realpathSync(abs)
|
||||
if (seen.has(real)) continue
|
||||
seen.add(real)
|
||||
files.push({ abs, real })
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan regex matches line by line and return the normalized matches rejected by
|
||||
* a caller predicate.
|
||||
* @param root - absolute repository root used for violation paths.
|
||||
* @param absPath - absolute text-file path to scan.
|
||||
* @param pattern - global regex matched independently against each line.
|
||||
* @param normalize - maps raw regex text to the reference the gate evaluates.
|
||||
* @param isViolation - returns true when the normalized reference is invalid.
|
||||
* @returns every rejected reference in source order.
|
||||
*/
|
||||
export function findReferenceViolations(
|
||||
root: string,
|
||||
absPath: string,
|
||||
pattern: RegExp,
|
||||
normalize: (raw: string) => string,
|
||||
isViolation: (ref: string) => boolean,
|
||||
): ReferenceViolation[] {
|
||||
const file = relative(root, absPath).split(sep).join('/')
|
||||
const out: ReferenceViolation[] = []
|
||||
const lines = readFileSync(absPath, 'utf8').split('\n')
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (line === undefined) continue
|
||||
for (const match of line.matchAll(pattern)) {
|
||||
const ref = normalize(match[0])
|
||||
if (isViolation(ref)) out.push({ file, line: i + 1, ref })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
+7
-17
@@ -1,23 +1,13 @@
|
||||
/**
|
||||
* 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. Pure module — no side effects on import.
|
||||
*
|
||||
* The layout contract ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md)):
|
||||
* every RFC lives at `docs/rfc/{lifecycle}/{class}/yyyy-mm-dd-topic.md`, the
|
||||
* folder IS the label, and both sets are CLOSED — extending either means
|
||||
* amending this module AND the README's Classification prose.
|
||||
*
|
||||
* The index (`docs/rfc/INDEX.md`) is GENERATED in full: per-lifecycle sections
|
||||
* whose rows are derived from each RFC's path (lifecycle/class), H1 (title,
|
||||
* with an optional `RFC: ` prefix stripped), and filename date, sorted by date
|
||||
* then filename. The curated prose lives in README.md, which carries no index
|
||||
* rows at all.
|
||||
* 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 } from 'node:path'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import { globSync } from 'node:fs'
|
||||
|
||||
export const rfcRoot = resolve(import.meta.dirname, '../docs/rfc')
|
||||
@@ -68,7 +58,7 @@ export function walkRfcTree(): { rfcs: Rfc[]; errors: string[] } {
|
||||
}
|
||||
}
|
||||
for (const lifecycle of LIFECYCLES) {
|
||||
for (const match of globSync(`${lifecycle}/**/*.md`, { cwd: rfcRoot }).sort()) {
|
||||
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
|
||||
|
||||
+58
-23
@@ -96,8 +96,7 @@ function pnpmScript(id: string, script: string, options: Partial<Gate> = {}): Ga
|
||||
return {
|
||||
id,
|
||||
label: options.label ?? script,
|
||||
command: pnpmBin(),
|
||||
args: ['run', script],
|
||||
...pnpmInvocation(['run', script]),
|
||||
...options,
|
||||
}
|
||||
}
|
||||
@@ -106,14 +105,18 @@ function pnpmExec(id: string, args: string[], options: Partial<Gate> = {}): Gate
|
||||
return {
|
||||
id,
|
||||
label: options.label ?? `pnpm exec ${args.join(' ')}`,
|
||||
command: pnpmBin(),
|
||||
args: ['exec', ...args],
|
||||
...pnpmInvocation(['exec', ...args]),
|
||||
...options,
|
||||
}
|
||||
}
|
||||
|
||||
function pnpmBin(): string {
|
||||
return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
function pnpmInvocation(args: string[]): Pick<Gate, 'command' | 'args'> {
|
||||
const entrypoint = process.env.npm_execpath
|
||||
if (entrypoint === undefined || entrypoint === '') {
|
||||
throw new Error('run-gates: npm_execpath is unavailable; invoke the runner through a pnpm package script.')
|
||||
}
|
||||
// Windows cannot spawn the pnpm.cmd shim directly; the JavaScript entrypoint keeps every host shell-free.
|
||||
return { command: process.execPath, args: [entrypoint, ...args] }
|
||||
}
|
||||
|
||||
function nodeOptions(...options: string[]): string {
|
||||
@@ -129,6 +132,7 @@ function gatesForMode(selected: Mode): Gate[] {
|
||||
case 'ci-lint':
|
||||
return [
|
||||
lintGate(),
|
||||
pnpmScript('duplication', 'duplication'),
|
||||
]
|
||||
case 'ci-coverage':
|
||||
return [
|
||||
@@ -143,10 +147,18 @@ function gatesForMode(selected: Mode): Gate[] {
|
||||
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 [
|
||||
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
|
||||
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
|
||||
pnpmScript('test', 'test'),
|
||||
pnpmScript('duplication', 'duplication'),
|
||||
pnpmScript('snapshot', 'test:snapshot'),
|
||||
pnpmScript('build', 'build'),
|
||||
...hygieneLeafGates({ artifactNeeds: ['build'] }),
|
||||
@@ -158,9 +170,12 @@ function gatesForMode(selected: Mode): Gate[] {
|
||||
|
||||
function ciPrimaryGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
|
||||
pnpmScript('typecheck', 'typecheck'),
|
||||
lintGate(),
|
||||
pnpmScript('duplication', 'duplication'),
|
||||
coverageGate(),
|
||||
pnpmScript('snapshot', 'test:snapshot'),
|
||||
demoSmokeGate({ needs: ['lint'] }),
|
||||
@@ -180,8 +195,10 @@ function ciPrimaryGates(): Gate[] {
|
||||
|
||||
function ciStaticGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
demoSmokeGate(),
|
||||
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
|
||||
...staticDemoSmokeGates(),
|
||||
...docSyncLeafGates(),
|
||||
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
|
||||
pnpmScript('knip', 'knip'),
|
||||
@@ -189,6 +206,11 @@ function ciStaticGates(): Gate[] {
|
||||
]
|
||||
}
|
||||
|
||||
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'),
|
||||
@@ -264,17 +286,21 @@ function docSyncLeafGates(): Gate[] {
|
||||
pnpmScript('config-catalog', 'verify-config-catalog', { label: 'config catalog' }),
|
||||
pnpmScript('persistence-catalog', 'verify-persistence-catalog', { label: 'persistence catalog' }),
|
||||
pnpmScript('doc-graphs', 'verify-doc-graphs', { label: 'doc graphs' }),
|
||||
pnpmScript('scoped-events', 'verify-scoped-events', { label: 'scoped events' }),
|
||||
pnpmScript('website-api', 'verify-website-api', { label: 'website api' }),
|
||||
pnpmScript('markdown-wrap', 'verify-md-wrap', { label: 'markdown wrap' }),
|
||||
pnpmScript('markdown-links', 'verify-md-links', { label: 'markdown links' }),
|
||||
pnpmScript('doc-refs', 'verify-doc-refs', { label: 'doc refs' }),
|
||||
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('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' }),
|
||||
pnpmScript('package-readme-limitations', 'verify-package-readme-limitations', { label: 'package README limitations' }),
|
||||
pnpmScript('website-yaml', 'verify-website-yaml', { label: 'website yaml' }),
|
||||
]
|
||||
}
|
||||
@@ -284,24 +310,33 @@ function demoSmokeGate(options: { needs?: string[] } = {}): Gate {
|
||||
return {
|
||||
id: 'demo-smoke',
|
||||
label: 'demo smoke',
|
||||
command: pnpmBin(),
|
||||
args: ['run', 'demo:echo'],
|
||||
...pnpmInvocation(['run', 'demo:echo']),
|
||||
input: 'echo ci smoke\n',
|
||||
...dependencyOptions,
|
||||
verify: async (result) => {
|
||||
const output = result.stdout + result.stderr
|
||||
if (!output.includes('[tool call] echo({"text":"ci smoke"})')) {
|
||||
throw new Error('demo smoke did not show the echo tool call.')
|
||||
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 })
|
||||
}
|
||||
if (!output.includes('[tool result] ECHO: CI SMOKE')) {
|
||||
throw new Error('demo smoke did not show the echo tool result.')
|
||||
}
|
||||
const sessionDir = join(root, '.sessions', '_no-cwd')
|
||||
const entries = await readdir(sessionDir)
|
||||
if (!entries.some(entry => /^main-session-.+\.jsonl$/.test(entry))) {
|
||||
throw new Error('demo smoke did not create a main-session JSONL log.')
|
||||
}
|
||||
await rm(join(root, '.sessions'), { recursive: true, force: true })
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -312,10 +347,10 @@ function builtBinSmokeGate(): Gate {
|
||||
'run',
|
||||
'--config',
|
||||
'vitest.e2e.config.ts',
|
||||
'packages/ui/stdio-agent/tests/built-bin.e2e.ts',
|
||||
'packages/ui/acp-agent/tests/built-bin.e2e.ts',
|
||||
'packages/examples/stdio-demo/tests/built-bin.e2e.ts',
|
||||
'packages/examples/acp-demo/tests/built-bin.e2e.ts',
|
||||
// The worker-entry packages' built bundles: the only automated proof
|
||||
// that lib/index.js resolves its sibling lib/worker.js under plain node
|
||||
// that lib/index.js resolves its sibling lib/worker.cjs under plain node
|
||||
// (the e2e lane runs unbuilt, so these files self-skip there).
|
||||
'packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts',
|
||||
'packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts',
|
||||
|
||||
@@ -0,0 +1,794 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Keyless full-turn and snapshot smoke for the Python SDK runtime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import difflib
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from deepseek_harness import TurnResult
|
||||
|
||||
|
||||
EXPECTED_TEXT = "runtime smoke ok"
|
||||
CODE_PROMPT = "Use run_code to compute the packaged worker smoke value."
|
||||
CODE_WORKER_TEXT = "code worker smoke ok"
|
||||
WORKFLOW_PROMPT = "Use workflow to compute the packaged worker smoke value without agents."
|
||||
WORKFLOW_WORKER_TEXT = "workflow worker smoke ok"
|
||||
SNAPSHOT_PROMPT = "Run the advanced packaged-runtime snapshot scenario."
|
||||
SNAPSHOT_SESSION_ID = "advanced-executable"
|
||||
SNAPSHOT_DIRECT_CHILD_PROMPT = "Reply with exactly DIRECT_CHILD_OK and nothing else."
|
||||
SNAPSHOT_WORKFLOW_CHILD_PROMPT = "Reply with exactly WORKFLOW_CHILD_OK and nothing else."
|
||||
SNAPSHOT_FINAL_TEXT = "ADVANCED_EXECUTABLE_OK"
|
||||
SNAPSHOT_MOUNT_CODE = """\
|
||||
return (ctx) => {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'snapshot_double',
|
||||
description: 'Double a number for executable snapshot verification.',
|
||||
parameters: { value: { type: 'number', required: true } },
|
||||
async execute(args) {
|
||||
return [{ type: 'text', text: String(args.value * 2) }]
|
||||
}
|
||||
}))
|
||||
}
|
||||
"""
|
||||
SNAPSHOT_WORKFLOW_SCRIPT = (
|
||||
"phase('Delegate')\n"
|
||||
f"const reply = await agent('{SNAPSHOT_WORKFLOW_CHILD_PROMPT}', {{ label: 'workflow-child' }})\n"
|
||||
"return { reply }"
|
||||
)
|
||||
SNAPSHOT_DIRECTORY = (
|
||||
Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "advanced"
|
||||
)
|
||||
SNAPSHOT_FILENAMES = ("result.json", "session.jsonl", "session.1.jsonl", "session.2.jsonl")
|
||||
CUSTOM_CORDIS = """\
|
||||
- id: jsonrpc
|
||||
name: '@deepseek-ai/dsh-jsonrpc'
|
||||
- id: agent-core
|
||||
name: '@deepseek-ai/dsh-agent-spine-demo'
|
||||
config:
|
||||
tools:
|
||||
mode: both
|
||||
- id: sessions
|
||||
name: '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
config:
|
||||
root: !!js process.env.DSH_SESSION_ROOT
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
config:
|
||||
cwd: !!js process.env.DSH_CWD
|
||||
- id: code-runtime
|
||||
name: '@deepseek-ai/dsh-code-runtime-worker'
|
||||
- id: subagents
|
||||
name: '@deepseek-ai/dsh-subagent'
|
||||
- id: subagent-spawn
|
||||
name: '@deepseek-ai/dsh-subagent-spawn'
|
||||
config:
|
||||
providerName: spawn
|
||||
- id: subagent-tool
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config:
|
||||
provider: spawn
|
||||
- id: workflow-engine
|
||||
name: '@deepseek-ai/dsh-workflow-workerthread'
|
||||
config:
|
||||
provider: spawn
|
||||
- id: workflow-tool
|
||||
name: '@deepseek-ai/dsh-tool-workflow'
|
||||
- id: cordis-tool
|
||||
name: '@deepseek-ai/dsh-tool-cordis'
|
||||
"""
|
||||
|
||||
|
||||
class MockModelHandler(BaseHTTPRequestHandler):
|
||||
"""Return deterministic text, worker, and orchestration completions."""
|
||||
|
||||
requests: list[dict[str, object]] = []
|
||||
|
||||
def do_POST(self) -> None:
|
||||
content_length = int(self.headers.get("content-length", "0"))
|
||||
body = json.loads(self.rfile.read(content_length))
|
||||
self.requests.append(body)
|
||||
self.send_response(200)
|
||||
self.send_header("content-type", "text/event-stream")
|
||||
self.end_headers()
|
||||
chunks = completion_chunks(body)
|
||||
for chunk in chunks:
|
||||
self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode())
|
||||
self.wfile.write(b"data: [DONE]\n\n")
|
||||
self.wfile.flush()
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
return
|
||||
|
||||
|
||||
def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
|
||||
"""Choose the next deterministic model response from request history."""
|
||||
messages = body.get("messages")
|
||||
if not isinstance(messages, list) or not messages:
|
||||
raise AssertionError(f"model request has no messages: {body}")
|
||||
latest = messages[-1]
|
||||
if not isinstance(latest, dict):
|
||||
raise AssertionError(f"model request has an invalid latest message: {body}")
|
||||
|
||||
if latest.get("role") == "tool":
|
||||
call_id, tool_name = latest_tool_call(messages)
|
||||
tool_text = message_text(latest.get("content"))
|
||||
advanced = advanced_tool_followup(body, call_id, tool_name, tool_text)
|
||||
if advanced is not None:
|
||||
return advanced
|
||||
if "42" not in tool_text:
|
||||
raise AssertionError(f"{tool_name} worker returned no expected value: {latest}")
|
||||
if tool_name == "run_code":
|
||||
return text_chunks(CODE_WORKER_TEXT)
|
||||
if tool_name == "workflow":
|
||||
return text_chunks(WORKFLOW_WORKER_TEXT)
|
||||
raise AssertionError(f"unexpected tool follow-up: {tool_name}")
|
||||
|
||||
prompt = message_text(latest.get("content"))
|
||||
if prompt == SNAPSHOT_DIRECT_CHILD_PROMPT:
|
||||
return text_chunks("DIRECT_CHILD_OK")
|
||||
if prompt == SNAPSHOT_WORKFLOW_CHILD_PROMPT:
|
||||
return text_chunks("WORKFLOW_CHILD_OK")
|
||||
if prompt == SNAPSHOT_PROMPT:
|
||||
assert_advertised_tool(body, "cordis_mount")
|
||||
return tool_call_chunks(
|
||||
"advanced-mount",
|
||||
"cordis_mount",
|
||||
{"code": SNAPSHOT_MOUNT_CODE},
|
||||
)
|
||||
if prompt == CODE_PROMPT:
|
||||
assert_advertised_tool(body, "run_code")
|
||||
return tool_call_chunks("call-code-worker", "run_code", {"code": "return 6 * 7"})
|
||||
if prompt == WORKFLOW_PROMPT:
|
||||
assert_advertised_tool(body, "workflow")
|
||||
return tool_call_chunks(
|
||||
"call-workflow-worker",
|
||||
"workflow",
|
||||
{
|
||||
"script": "return 6 * 7",
|
||||
"meta": {
|
||||
"name": "pkg-worker-smoke",
|
||||
"description": "exercise the packaged workflow worker",
|
||||
},
|
||||
},
|
||||
)
|
||||
return text_chunks(EXPECTED_TEXT)
|
||||
|
||||
|
||||
def advanced_tool_followup(
|
||||
body: dict[str, object],
|
||||
call_id: str,
|
||||
tool_name: str,
|
||||
tool_text: str,
|
||||
) -> list[dict[str, object]] | None:
|
||||
"""Advance the executable snapshot's deterministic parent tool chain."""
|
||||
if not call_id.startswith("advanced-"):
|
||||
return None
|
||||
if call_id == "advanced-mount" and tool_name == "cordis_mount":
|
||||
if "mounted dyn-1" not in tool_text:
|
||||
raise AssertionError(f"cordis_mount returned no mount id: {tool_text}")
|
||||
assert_advertised_tool(body, "run_code")
|
||||
assert_advertised_tool(body, "snapshot_double")
|
||||
return tool_call_chunks(
|
||||
"advanced-code",
|
||||
"run_code",
|
||||
{"code": "return await tools.snapshot_double({ value: 21 })"},
|
||||
)
|
||||
if call_id == "advanced-code" and tool_name == "run_code":
|
||||
if "42" not in tool_text:
|
||||
raise AssertionError(f"run_code returned no dynamic-tool value: {tool_text}")
|
||||
assert_advertised_tool(body, "subagent")
|
||||
return tool_call_chunks(
|
||||
"advanced-direct-child",
|
||||
"subagent",
|
||||
{
|
||||
"description": "Check direct child",
|
||||
"prompt": SNAPSHOT_DIRECT_CHILD_PROMPT,
|
||||
},
|
||||
)
|
||||
if call_id == "advanced-direct-child" and tool_name == "subagent":
|
||||
if "DIRECT_CHILD_OK" not in tool_text:
|
||||
raise AssertionError(f"subagent returned no expected child value: {tool_text}")
|
||||
assert_advertised_tool(body, "workflow")
|
||||
return tool_call_chunks(
|
||||
"advanced-workflow",
|
||||
"workflow",
|
||||
{
|
||||
"script": SNAPSHOT_WORKFLOW_SCRIPT,
|
||||
"meta": {
|
||||
"name": "advanced-exe-snapshot",
|
||||
"description": "exercise one packaged workflow child",
|
||||
},
|
||||
},
|
||||
)
|
||||
if call_id == "advanced-workflow" and tool_name == "workflow":
|
||||
if "WORKFLOW_CHILD_OK" not in tool_text:
|
||||
raise AssertionError(f"workflow returned no expected child value: {tool_text}")
|
||||
assert_advertised_tool(body, "cordis_unmount")
|
||||
return tool_call_chunks(
|
||||
"advanced-unmount",
|
||||
"cordis_unmount",
|
||||
{"id": "dyn-1"},
|
||||
)
|
||||
if call_id == "advanced-unmount" and tool_name == "cordis_unmount":
|
||||
if "unmounted dyn-1" not in tool_text:
|
||||
raise AssertionError(f"cordis_unmount returned no disposal result: {tool_text}")
|
||||
if "snapshot_double" in advertised_tool_names(body):
|
||||
raise AssertionError("snapshot_double remained advertised after cordis_unmount")
|
||||
return text_chunks(SNAPSHOT_FINAL_TEXT)
|
||||
raise AssertionError(f"unexpected advanced tool follow-up: {call_id} {tool_name}: {tool_text}")
|
||||
|
||||
|
||||
def text_chunks(text: str) -> list[dict[str, object]]:
|
||||
"""Build a complete streaming text response."""
|
||||
return [
|
||||
{"choices": [{"delta": {"role": "assistant", "content": None, "reasoning_content": ""}}]},
|
||||
{"choices": [{"delta": {"content": text}}]},
|
||||
{
|
||||
"choices": [{"delta": {"content": ""}, "finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": 3, "completion_tokens": 3},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def tool_call_chunks(call_id: str, name: str, arguments: dict[str, object]) -> list[dict[str, object]]:
|
||||
"""Build a complete streaming function-call response."""
|
||||
return [
|
||||
{"choices": [{"delta": {"role": "assistant", "content": None, "reasoning_content": ""}}]},
|
||||
{
|
||||
"choices": [{
|
||||
"delta": {
|
||||
"tool_calls": [{
|
||||
"index": 0,
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {"name": name, "arguments": json.dumps(arguments)},
|
||||
}],
|
||||
},
|
||||
}],
|
||||
},
|
||||
{
|
||||
"choices": [{"delta": {"content": ""}, "finish_reason": "tool_calls"}],
|
||||
"usage": {"prompt_tokens": 3, "completion_tokens": 3},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def latest_tool_call(messages: list[object]) -> tuple[str, str]:
|
||||
"""Find the assistant call id and name paired with the latest tool result."""
|
||||
for message in reversed(messages[:-1]):
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
calls = message.get("tool_calls")
|
||||
if not isinstance(calls, list):
|
||||
continue
|
||||
for call in reversed(calls):
|
||||
if not isinstance(call, dict):
|
||||
continue
|
||||
function = call.get("function")
|
||||
call_id = call.get("id")
|
||||
if (
|
||||
isinstance(call_id, str)
|
||||
and isinstance(function, dict)
|
||||
and isinstance(function.get("name"), str)
|
||||
):
|
||||
return call_id, function["name"]
|
||||
raise AssertionError(f"tool result has no preceding assistant tool call: {messages}")
|
||||
|
||||
|
||||
def message_text(content: object) -> str:
|
||||
"""Read OpenAI text content in either string or block-list form."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
return "".join(
|
||||
block.get("text", "")
|
||||
for block in content
|
||||
if isinstance(block, dict) and isinstance(block.get("text"), str)
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def advertised_tool_names(body: dict[str, object]) -> set[str]:
|
||||
"""Return the model-facing tool names advertised on one request."""
|
||||
tools = body.get("tools")
|
||||
if not isinstance(tools, list):
|
||||
raise AssertionError(f"model request advertised no tools: {body}")
|
||||
names: set[str] = set()
|
||||
for tool in tools:
|
||||
if not isinstance(tool, dict):
|
||||
continue
|
||||
function = tool.get("function")
|
||||
if isinstance(function, dict) and isinstance(function.get("name"), str):
|
||||
names.add(function["name"])
|
||||
return names
|
||||
|
||||
|
||||
def assert_advertised_tool(body: dict[str, object], expected: str) -> None:
|
||||
"""Require the packaged deployment to expose the requested tool."""
|
||||
names = advertised_tool_names(body)
|
||||
if expected not in names:
|
||||
raise AssertionError(f"model request did not advertise {expected}: {names}")
|
||||
|
||||
|
||||
class MockModel:
|
||||
def __enter__(self) -> "MockModel":
|
||||
MockModelHandler.requests.clear()
|
||||
self.server = ThreadingHTTPServer(("127.0.0.1", 0), MockModelHandler)
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
host, port = self.server.server_address
|
||||
self.url = f"http://{host}:{port}"
|
||||
return self
|
||||
|
||||
def __exit__(self, _exc_type: object, _exc: object, _tb: object) -> None:
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=5)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--scenario",
|
||||
choices=("all", "sdk-default", "sdk-custom", "sdk-snapshot", "direct"),
|
||||
default="all",
|
||||
)
|
||||
parser.add_argument("--exe", type=Path)
|
||||
parser.add_argument("--update-snapshots", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.scenario in {"all", "sdk-custom", "sdk-snapshot", "direct"} and args.exe is None:
|
||||
parser.error("--exe is required for custom, snapshot, and direct scenarios")
|
||||
if args.update_snapshots and args.scenario not in {"all", "sdk-snapshot"}:
|
||||
parser.error("--update-snapshots requires --scenario sdk-snapshot or all")
|
||||
if args.exe is not None and not args.exe.is_file():
|
||||
parser.error(f"runtime executable does not exist: {args.exe}")
|
||||
|
||||
with MockModel() as model:
|
||||
if args.scenario in {"all", "sdk-default"}:
|
||||
smoke_sdk_default(model.url)
|
||||
if args.scenario in {"all", "sdk-custom"}:
|
||||
assert args.exe is not None
|
||||
smoke_sdk_custom(model.url, args.exe.resolve())
|
||||
if args.scenario in {"all", "sdk-snapshot"}:
|
||||
assert args.exe is not None
|
||||
smoke_sdk_snapshot(model.url, args.exe.resolve(), args.update_snapshots)
|
||||
if args.scenario in {"all", "direct"}:
|
||||
assert args.exe is not None
|
||||
smoke_direct(model.url, args.exe.resolve())
|
||||
if not MockModelHandler.requests:
|
||||
raise AssertionError("mock model endpoint received no requests")
|
||||
print(f"smoke-python-runtime: {args.scenario} passed")
|
||||
|
||||
|
||||
def smoke_sdk_default(base_url: str) -> None:
|
||||
from deepseek_harness import DeepSeekHarness
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="dsh-sdk-default-") as temporary:
|
||||
root = Path(temporary).resolve()
|
||||
sessions = root / "sessions"
|
||||
with DeepSeekHarness(
|
||||
model="smoke-model",
|
||||
cwd=str(root),
|
||||
session_root=str(sessions),
|
||||
api_key="sk-keyless-smoke",
|
||||
base_url=base_url,
|
||||
request_timeout_seconds=60,
|
||||
) as harness:
|
||||
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)
|
||||
|
||||
|
||||
def smoke_sdk_custom(base_url: str, executable: Path) -> None:
|
||||
from deepseek_harness import DeepSeekHarness
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="dsh-sdk-custom-") as temporary:
|
||||
root = Path(temporary).resolve()
|
||||
sessions = root / "sessions"
|
||||
cordis = root / "cordis.yml"
|
||||
cordis.write_text(CUSTOM_CORDIS)
|
||||
with DeepSeekHarness(
|
||||
model="smoke-model",
|
||||
cwd=str(root),
|
||||
session_root=str(sessions),
|
||||
cordis=str(cordis),
|
||||
runtime_bin=str(executable),
|
||||
api_key="sk-keyless-smoke",
|
||||
base_url=base_url,
|
||||
request_timeout_seconds=60,
|
||||
) as harness:
|
||||
text_result = harness.run("reply with the smoke text", session_id="custom-smoke")
|
||||
code_result = harness.run(CODE_PROMPT, session_id="custom-smoke")
|
||||
workflow_result = harness.run(WORKFLOW_PROMPT, session_id="custom-smoke")
|
||||
assert text_result.status == "ok", text_result
|
||||
assert text_result.final_response == EXPECTED_TEXT, text_result.final_response
|
||||
assert code_result.status == "ok", code_result
|
||||
assert code_result.final_response == CODE_WORKER_TEXT, code_result.final_response
|
||||
assert workflow_result.status == "ok", workflow_result
|
||||
assert workflow_result.final_response == WORKFLOW_WORKER_TEXT, workflow_result.final_response
|
||||
assert_session_log(sessions, root, EXPECTED_TEXT, CODE_WORKER_TEXT, WORKFLOW_WORKER_TEXT)
|
||||
|
||||
|
||||
def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None:
|
||||
"""Drive and compare the advanced SDK/executable behavioral snapshot."""
|
||||
from deepseek_harness import DeepSeekHarness
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="dsh-sdk-snapshot-") as temporary:
|
||||
root = Path(temporary).resolve()
|
||||
sessions = root / "sessions"
|
||||
cordis = root / "cordis.yml"
|
||||
cordis.write_text(CUSTOM_CORDIS)
|
||||
with DeepSeekHarness(
|
||||
model="smoke-model",
|
||||
cwd=str(root),
|
||||
session_root=str(sessions),
|
||||
cordis=str(cordis),
|
||||
runtime_bin=str(executable),
|
||||
api_key="sk-keyless-smoke",
|
||||
base_url=base_url,
|
||||
request_timeout_seconds=60,
|
||||
) as harness:
|
||||
result = harness.run(SNAPSHOT_PROMPT, session_id=SNAPSHOT_SESSION_ID)
|
||||
|
||||
assert result.status == "ok", result
|
||||
assert result.final_response == SNAPSHOT_FINAL_TEXT, result.final_response
|
||||
methods = [notification.method for notification in result.notifications]
|
||||
if methods.count("subagent.started") != 2 or methods.count("subagent.finished") != 2:
|
||||
raise AssertionError(f"advanced snapshot emitted unexpected subagent lifecycle: {methods}")
|
||||
if not any(event.get("type") == "tool/code-dispatch" for event in result.events):
|
||||
raise AssertionError("advanced snapshot emitted no tool/code-dispatch event")
|
||||
|
||||
logs = read_session_logs(sessions)
|
||||
child_ids = snapshot_child_ids(result)
|
||||
expected_ids = {SNAPSHOT_SESSION_ID, *child_ids}
|
||||
if set(logs) != expected_ids:
|
||||
raise AssertionError(f"advanced snapshot expected parent plus two child logs: {sorted(logs)}")
|
||||
if "DIRECT_CHILD_OK" not in render_jsonl(logs[child_ids[0]]):
|
||||
raise AssertionError("first advanced child log has no direct-subagent result")
|
||||
if "WORKFLOW_CHILD_OK" not in render_jsonl(logs[child_ids[1]]):
|
||||
raise AssertionError("second advanced child log has no workflow-subagent result")
|
||||
|
||||
files = build_snapshot_files(result, logs, child_ids, root)
|
||||
compare_snapshot_files(files, update_snapshots)
|
||||
|
||||
|
||||
def smoke_direct(base_url: str, executable: Path) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="dsh-direct-") as temporary:
|
||||
root = Path(temporary).resolve()
|
||||
sessions = root / "sessions"
|
||||
cordis = root / "cordis.yml"
|
||||
cordis.write_text(CUSTOM_CORDIS)
|
||||
environment = {
|
||||
**os.environ,
|
||||
"DSH_CORDIS_CONFIG": str(cordis),
|
||||
"DSH_SESSION_ROOT": str(sessions),
|
||||
"DSH_CWD": str(root),
|
||||
"DEEPSEEK_API_KEY": "sk-keyless-smoke",
|
||||
"DEEPSEEK_BASE_URL": base_url,
|
||||
}
|
||||
peer = RuntimePeer([str(executable)], root, environment)
|
||||
try:
|
||||
peer.send({"jsonrpc": "2.0", "id": "initialize", "method": "initialize", "params": {"cwd": str(root), "model": "smoke-model"}})
|
||||
peer.read_until(lambda message: message.get("id") == "initialize")
|
||||
peer.send({
|
||||
"jsonrpc": "2.0",
|
||||
"id": "prompt",
|
||||
"method": "session/prompt",
|
||||
"params": {"sessionId": "direct-smoke", "contentBlocks": [{"type": "text", "text": "reply with the smoke text"}]},
|
||||
})
|
||||
messages = peer.read_until(lambda message: message.get("id") == "prompt")
|
||||
if not any(message.get("method") == "session.finished" and message.get("params", {}).get("status") == "ok" for message in messages):
|
||||
messages.extend(peer.read_until(lambda message: message.get("method") == "session.finished"))
|
||||
event_text = json.dumps(messages)
|
||||
if EXPECTED_TEXT not in event_text:
|
||||
raise AssertionError(f"direct runtime emitted no final response: {messages}")
|
||||
peer.send({"jsonrpc": "2.0", "id": "shutdown", "method": "shutdown"})
|
||||
peer.read_until(lambda message: message.get("id") == "shutdown")
|
||||
finally:
|
||||
peer.close()
|
||||
assert_session_log(sessions, root, EXPECTED_TEXT)
|
||||
|
||||
|
||||
class RuntimePeer:
|
||||
def __init__(self, argv: list[str], cwd: Path, environment: dict[str, str]) -> None:
|
||||
self.process = subprocess.Popen(
|
||||
argv,
|
||||
cwd=cwd,
|
||||
env=environment,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
bufsize=1,
|
||||
)
|
||||
self.stdout: queue.Queue[str | None] = queue.Queue()
|
||||
self.stderr: list[str] = []
|
||||
threading.Thread(target=self._read_stdout, daemon=True).start()
|
||||
threading.Thread(target=self._read_stderr, daemon=True).start()
|
||||
|
||||
def send(self, message: dict[str, object]) -> None:
|
||||
if self.process.stdin is None:
|
||||
raise RuntimeError("runtime stdin is unavailable")
|
||||
self.process.stdin.write(json.dumps(message) + "\n")
|
||||
self.process.stdin.flush()
|
||||
|
||||
def read_until(self, predicate: Callable[[dict[str, object]], bool]) -> list[dict[str, object]]:
|
||||
deadline = time.monotonic() + 60
|
||||
messages: list[dict[str, object]] = []
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
line = self.stdout.get(timeout=min(0.25, deadline - time.monotonic()))
|
||||
except queue.Empty:
|
||||
continue
|
||||
if line is None:
|
||||
raise RuntimeError(f"runtime exited before expected message; stderr: {''.join(self.stderr)}")
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
messages.append(message)
|
||||
if predicate(message):
|
||||
return messages
|
||||
raise TimeoutError(f"runtime timed out; messages={messages}; stderr={''.join(self.stderr)}")
|
||||
|
||||
def close(self) -> None:
|
||||
if self.process.stdin is not None and not self.process.stdin.closed:
|
||||
self.process.stdin.close()
|
||||
try:
|
||||
self.process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.kill()
|
||||
self.process.wait()
|
||||
if self.process.returncode not in {0, -15}:
|
||||
raise RuntimeError(f"runtime exited {self.process.returncode}; stderr: {''.join(self.stderr)}")
|
||||
|
||||
def _read_stdout(self) -> None:
|
||||
assert self.process.stdout is not None
|
||||
for line in self.process.stdout:
|
||||
self.stdout.put(line)
|
||||
self.stdout.put(None)
|
||||
|
||||
def _read_stderr(self) -> None:
|
||||
assert self.process.stderr is not None
|
||||
self.stderr.extend(self.process.stderr)
|
||||
|
||||
|
||||
def assert_session_log(sessions: Path, cwd: Path, *expected_texts: str) -> None:
|
||||
logs = list(sessions.rglob("*.jsonl"))
|
||||
if len(logs) != 1:
|
||||
raise AssertionError(f"expected one JSONL session log under {sessions}, found {logs}")
|
||||
lines = logs[0].read_text().splitlines()
|
||||
header = json.loads(lines[0])
|
||||
if header.get("cwd") != str(cwd):
|
||||
raise AssertionError(f"session header cwd is not absolute/canonical: {header}")
|
||||
rendered = "\n".join(lines)
|
||||
for expected in expected_texts:
|
||||
if expected not in rendered:
|
||||
raise AssertionError(f"session log has no {expected!r} response: {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]]] = {}
|
||||
for path in sorted(sessions.rglob("*.jsonl")):
|
||||
records = [
|
||||
json.loads(line)
|
||||
for line in path.read_text(encoding="utf-8").splitlines()
|
||||
if line
|
||||
]
|
||||
if not records or records[0].get("type") != "session":
|
||||
raise AssertionError(f"session log has no header: {path}")
|
||||
session_id = records[0].get("id")
|
||||
if not isinstance(session_id, str):
|
||||
raise AssertionError(f"session log header has no string id: {path}")
|
||||
if session_id in logs:
|
||||
raise AssertionError(f"duplicate persisted session id: {session_id}")
|
||||
logs[session_id] = records
|
||||
return logs
|
||||
|
||||
|
||||
def snapshot_child_ids(result: "TurnResult") -> list[str]:
|
||||
"""Return the two child session ids in their SDK notification order."""
|
||||
child_ids: list[str] = []
|
||||
for notification in result.notifications:
|
||||
if notification.method != "subagent.started":
|
||||
continue
|
||||
payload = notification.payload
|
||||
if payload.get("parentSessionId") != SNAPSHOT_SESSION_ID:
|
||||
continue
|
||||
child_id = payload.get("childSessionId")
|
||||
if isinstance(child_id, str) and child_id not in child_ids:
|
||||
child_ids.append(child_id)
|
||||
if len(child_ids) != 2:
|
||||
raise AssertionError(f"advanced snapshot expected two child session ids: {child_ids}")
|
||||
return child_ids
|
||||
|
||||
|
||||
def build_snapshot_files(
|
||||
result: "TurnResult",
|
||||
logs: dict[str, list[dict[str, object]]],
|
||||
child_ids: list[str],
|
||||
cwd: Path,
|
||||
) -> dict[str, str]:
|
||||
"""Render the SDK result and three persisted logs into stable goldens."""
|
||||
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}}}}}"))
|
||||
agent_id = snapshot_agent_id(result, child_id)
|
||||
replacements.append((agent_id, f"{{{{agent-{index}}}}}"))
|
||||
replacements.sort(key=lambda pair: len(pair[0]), reverse=True)
|
||||
|
||||
result_value = {
|
||||
"session_id": result.session_id,
|
||||
"status": result.status,
|
||||
"final_response": result.final_response,
|
||||
"events": result.events,
|
||||
"notifications": [
|
||||
{"method": notification.method, "payload": notification.payload}
|
||||
for notification in result.notifications
|
||||
],
|
||||
"session_root": result.session_root,
|
||||
}
|
||||
normalized_result = normalize_snapshot_value(result_value, replacements)
|
||||
files = {
|
||||
"result.json": json.dumps(normalized_result, indent=2, ensure_ascii=False) + "\n",
|
||||
"session.jsonl": render_jsonl(
|
||||
[normalize_snapshot_value(record, replacements) for record in logs[SNAPSHOT_SESSION_ID]]
|
||||
),
|
||||
}
|
||||
for index, child_id in enumerate(child_ids, start=1):
|
||||
files[f"session.{index}.jsonl"] = render_jsonl(
|
||||
[normalize_snapshot_value(record, replacements) for record in logs[child_id]]
|
||||
)
|
||||
if tuple(files) != SNAPSHOT_FILENAMES:
|
||||
raise AssertionError(f"advanced snapshot file set drifted: {tuple(files)}")
|
||||
return files
|
||||
|
||||
|
||||
def snapshot_agent_id(result: "TurnResult", child_id: str) -> str:
|
||||
"""Find the successful subagent id paired with one child session."""
|
||||
for notification in result.notifications:
|
||||
if notification.method != "subagent.finished":
|
||||
continue
|
||||
payload = notification.payload
|
||||
if payload.get("childSessionId") != child_id:
|
||||
continue
|
||||
if payload.get("provider") != "spawn" or payload.get("status") != "ok":
|
||||
raise AssertionError(f"advanced child did not finish successfully: {payload}")
|
||||
agent_id = payload.get("agentId")
|
||||
if isinstance(agent_id, str):
|
||||
return agent_id
|
||||
raise AssertionError(f"advanced snapshot has no finished agent for child {child_id}")
|
||||
|
||||
|
||||
def normalize_snapshot_value(
|
||||
value: object,
|
||||
replacements: list[tuple[str, str]],
|
||||
) -> object:
|
||||
"""Scrub volatile values and bulky request headers without losing behavior."""
|
||||
if isinstance(value, str):
|
||||
normalized = value
|
||||
for actual, token in replacements:
|
||||
normalized = normalized.replace(actual, token)
|
||||
return normalized
|
||||
if isinstance(value, list):
|
||||
return [normalize_snapshot_value(item, replacements) for item in value]
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
|
||||
normalized = {
|
||||
key: normalize_snapshot_value(item, replacements)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if normalized.get("type") == "session" and "createdAt" in normalized:
|
||||
normalized["createdAt"] = 0
|
||||
if "seq" in normalized and "time" in normalized:
|
||||
normalized["time"] = 0
|
||||
scrub_snapshot_header(normalized)
|
||||
return normalized
|
||||
|
||||
|
||||
def scrub_snapshot_header(value: dict[object, object]) -> None:
|
||||
"""Tokenize request-header bulk while retaining delta tool names."""
|
||||
data = value.get("data")
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
if value.get("type") == "request/header":
|
||||
header = data.get("header")
|
||||
if not isinstance(header, dict):
|
||||
return
|
||||
if "system" in header:
|
||||
header["system"] = "{{system}}"
|
||||
tools = header.get("tools")
|
||||
if isinstance(tools, list):
|
||||
header["tools"] = [
|
||||
tool.get("name") if isinstance(tool, dict) else "{{tools}}"
|
||||
for tool in tools
|
||||
]
|
||||
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:
|
||||
"""Render parsed JSON values as compact, newline-terminated JSONL."""
|
||||
return "".join(
|
||||
json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
for record in records
|
||||
)
|
||||
|
||||
|
||||
def compare_snapshot_files(files: dict[str, str], update: bool) -> None:
|
||||
"""Write or exactly compare the advanced executable snapshot files."""
|
||||
if update:
|
||||
SNAPSHOT_DIRECTORY.mkdir(parents=True, exist_ok=True)
|
||||
for name, content in files.items():
|
||||
(SNAPSHOT_DIRECTORY / name).write_text(content, encoding="utf-8")
|
||||
print(f"smoke-python-runtime: updated snapshots in {SNAPSHOT_DIRECTORY}")
|
||||
|
||||
existing = {
|
||||
path.name
|
||||
for path in SNAPSHOT_DIRECTORY.iterdir()
|
||||
if path.is_file()
|
||||
} if SNAPSHOT_DIRECTORY.is_dir() else set()
|
||||
expected = set(SNAPSHOT_FILENAMES)
|
||||
if existing != expected:
|
||||
raise AssertionError(
|
||||
"advanced snapshot files differ: "
|
||||
f"missing={sorted(expected - existing)}, unexpected={sorted(existing - expected)}"
|
||||
)
|
||||
for name, actual in files.items():
|
||||
expected_text = (SNAPSHOT_DIRECTORY / name).read_text(encoding="utf-8")
|
||||
if actual == expected_text:
|
||||
continue
|
||||
diff = "".join(difflib.unified_diff(
|
||||
expected_text.splitlines(keepends=True),
|
||||
actual.splitlines(keepends=True),
|
||||
fromfile=f"expected/{name}",
|
||||
tofile=f"actual/{name}",
|
||||
))
|
||||
raise AssertionError(
|
||||
f"advanced executable snapshot mismatch in {name}; "
|
||||
"rerun with --update-snapshots after reviewing the behavior\n"
|
||||
f"{diff}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
{"type":"session","version":0,"id":"{{child-1}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}"}
|
||||
{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"}
|
||||
{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":3,"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":"initial"}}
|
||||
{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}}
|
||||
{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}}
|
||||
{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":10,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":11,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,13 @@
|
||||
{"type":"session","version":0,"id":"{{child-2}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}"}
|
||||
{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"}
|
||||
{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":3,"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":"initial"}}
|
||||
{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}}
|
||||
{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}}
|
||||
{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":10,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":11,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,66 @@
|
||||
{"type":"session","version":0,"id":"{{parent}}","createdAt":0,"cwd":"{{cwd}}"}
|
||||
{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"}},"surfaceOp":"append"}
|
||||
{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":3,"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":"initial"}}
|
||||
{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}"}}}
|
||||
{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}"}}
|
||||
{"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":"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 })\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}}
|
||||
{"type":"tool/code-dispatch","seq":22,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"resultSummary":"42"}}
|
||||
{"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[21],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}}
|
||||
{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}}
|
||||
{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}
|
||||
{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":31,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[26,27,28,29,30],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":32,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}
|
||||
{"type":"tool/result","seq":33,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false},"sourceEventSeqs":[32],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":34,"time":0,"data":{"turn":1,"step":3}}
|
||||
{"type":"step/start","seq":35,"time":0,"data":{"turn":1,"step":4}}
|
||||
{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}}
|
||||
{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}}}
|
||||
{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":41,"time":0,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[36,37,38,39,40],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":42,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}
|
||||
{"type":"tool/result","seq":43,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[42],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":44,"time":0,"data":{"turn":1,"step":4}}
|
||||
{"type":"step/start","seq":45,"time":0,"data":{"turn":1,"step":5}}
|
||||
{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\": \"dyn-1\"}"}}}
|
||||
{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":51,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[46,47,48,49,50],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":52,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}
|
||||
{"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":"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"}}}}
|
||||
{"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":62,"time":0,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[57,58,59,60,61],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":63,"time":0,"data":{"turn":1,"step":6}}
|
||||
{"type":"turn/end","seq":64,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -1,18 +1,32 @@
|
||||
{
|
||||
"requiredSince": "2026-07-14",
|
||||
"required": [
|
||||
"README.md",
|
||||
"docs/cookbook/adding-a-package.md",
|
||||
"docs/cookbook/adding-a-tool.md",
|
||||
"docs/cookbook/adding-a-vendored-package.md",
|
||||
"docs/cookbook/adding-an-llm-adapter.md",
|
||||
"docs/cookbook/extension-cookbook.md",
|
||||
"docs/cookbook/responding-to-pr-review-on-a-stack.md",
|
||||
"docs/development.md",
|
||||
"docs/i18n/README.md",
|
||||
"docs/i18n/translation-rules.md",
|
||||
"docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.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",
|
||||
"python/README.md",
|
||||
"python/sdk-runtime/README.md",
|
||||
"python/sdk/README.md"
|
||||
],
|
||||
"excluded": [
|
||||
"docs/AGENTS.md",
|
||||
"docs/module-graph.md",
|
||||
"docs/config-catalog.md",
|
||||
"docs/tool-catalog.md",
|
||||
"docs/persistence-catalog.md",
|
||||
"docs/cordis-catalog/",
|
||||
"docs/i18n/terminology.md"
|
||||
"docs/i18n/style-samples.md",
|
||||
"docs/i18n/terminology.md",
|
||||
"docs/i18n/translation-prompt.md",
|
||||
"docs/module-graph.md",
|
||||
"docs/persistence-catalog.md",
|
||||
"docs/tool-catalog.md",
|
||||
"python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/** Regression tests for the bilingual cutoff and structural signature. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
datedDocumentDate,
|
||||
isIsoDate,
|
||||
parseTranslationMarkdown,
|
||||
parseTranslationPairingManifest,
|
||||
requiresPairByDate,
|
||||
translationStructureDiff,
|
||||
translationStructureSignature,
|
||||
} from './translation-pairing.ts'
|
||||
|
||||
function signature(markdown: string) {
|
||||
return translationStructureSignature(parseTranslationMarkdown(markdown), 'counterpart.zh.md')
|
||||
}
|
||||
|
||||
describe('translation pairing manifest', () => {
|
||||
it('accepts a real ISO cutoff and string-array fields', () => {
|
||||
expect(parseTranslationPairingManifest(JSON.stringify({
|
||||
requiredSince: '2026-07-14',
|
||||
required: ['README.md'],
|
||||
excluded: ['docs/generated/'],
|
||||
}))).toEqual({
|
||||
requiredSince: '2026-07-14',
|
||||
required: ['README.md'],
|
||||
excluded: ['docs/generated/'],
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['2026-7-14', '2026-02-29', '2026-13-01', 'not-a-date'])('rejects invalid cutoff %s', (cutoff) => {
|
||||
expect(isIsoDate(cutoff)).toBe(false)
|
||||
expect(() => parseTranslationPairingManifest(JSON.stringify({
|
||||
requiredSince: cutoff,
|
||||
required: [],
|
||||
excluded: [],
|
||||
}))).toThrow('requiredSince must be a valid YYYY-MM-DD date')
|
||||
})
|
||||
|
||||
it('rejects non-string manifest arrays', () => {
|
||||
expect(() => parseTranslationPairingManifest(JSON.stringify({
|
||||
requiredSince: '2026-07-14',
|
||||
required: [42],
|
||||
excluded: [],
|
||||
}))).toThrow('required must be an array of strings')
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
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('docs/release-notes-2026-07-14-alpha.md')).toBeUndefined()
|
||||
expect(requiresPairByDate('docs/release-notes-2026-07-14-alpha.md', cutoff)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('translation structural signature', () => {
|
||||
it('accepts matching list kinds, starts, and item counts', () => {
|
||||
const source = signature('3. One\n4. Two\n\n- A\n- B\n')
|
||||
const counterpart = signature('3. 一\n4. 二\n\n- 甲\n- 乙\n')
|
||||
expect(translationStructureDiff(source, counterpart)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects an altered ordered-list start', () => {
|
||||
const source = signature('3. One\n4. Two\n\n- A\n- B\n')
|
||||
const counterpart = signature('1. 一\n2. 二\n\n- 甲\n- 乙\n')
|
||||
expect(translationStructureDiff(source, counterpart)).toEqual([
|
||||
'list (kind, start, item count) #1 diverges between the pair: "ordered:start=3:items=2" vs "ordered:start=1:items=2"',
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects a missing list item', () => {
|
||||
const source = signature('- A\n- B\n')
|
||||
const counterpart = signature('- 甲\n')
|
||||
expect(translationStructureDiff(source, counterpart)).toEqual([
|
||||
'list (kind, start, item count) #1 diverges between the pair: "bullet:items=2" vs "bullet:items=1"',
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects altered table row or column counts', () => {
|
||||
const source = signature('| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n')
|
||||
const counterpart = signature('| 甲 | 乙 |\n|---|---|\n| 一 | 二 |\n')
|
||||
expect(translationStructureDiff(source, counterpart)).toEqual([
|
||||
'table (row x column count) #1 diverges between the pair: "3x2" vs "2x2"',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Pure parsing and structural helpers for the bilingual-document pairing
|
||||
* gate. Kept separate from the CLI so cutoff and signature behavior can be
|
||||
* regression-tested without reading or mutating the repository tree.
|
||||
*/
|
||||
|
||||
import { fromMarkdown } from 'mdast-util-from-markdown'
|
||||
import { gfmFromMarkdown } from 'mdast-util-gfm'
|
||||
import { gfm } from 'micromark-extension-gfm'
|
||||
import type { Nodes } from 'mdast'
|
||||
|
||||
/** Validated shape of `scripts/translation-pairing.manifest.json`. */
|
||||
export interface TranslationPairingManifest {
|
||||
required: string[]
|
||||
excluded: string[]
|
||||
/** Date-named documents on or after this day must merge bilingual. */
|
||||
requiredSince: string
|
||||
}
|
||||
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
|
||||
const DATED_DOCUMENT = /(?:^|\/)(\d{4}-\d{2}-\d{2})-[^/]*\.md$/
|
||||
|
||||
/** Whether a string names one real calendar day in canonical ISO form. */
|
||||
export function isIsoDate(value: string): boolean {
|
||||
if (!ISO_DATE.test(value)) return false
|
||||
const date = new Date(`${value}T00:00:00.000Z`)
|
||||
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
|
||||
}
|
||||
|
||||
/** Read one manifest string-array field or fail before enforcement starts. */
|
||||
function stringArrayField(record: Record<string, unknown>, field: 'required' | 'excluded'): string[] {
|
||||
const value = record[field]
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`translation-pairing.manifest.json: ${field} must be an array of strings`)
|
||||
}
|
||||
const entries: unknown[] = value
|
||||
if (!entries.every((entry): entry is string => typeof entry === 'string')) {
|
||||
throw new Error(`translation-pairing.manifest.json: ${field} must be an array of strings`)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/** Parse and validate the checked-in bilingual manifest. */
|
||||
export function parseTranslationPairingManifest(content: string): TranslationPairingManifest {
|
||||
const value: unknown = JSON.parse(content)
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new Error('translation-pairing.manifest.json: expected an object')
|
||||
}
|
||||
const record = value as Record<string, unknown>
|
||||
const requiredSince = record.requiredSince
|
||||
if (typeof requiredSince !== 'string' || !isIsoDate(requiredSince)) {
|
||||
throw new Error(`translation-pairing.manifest.json: requiredSince must be a valid YYYY-MM-DD date; got ${JSON.stringify(requiredSince)}`)
|
||||
}
|
||||
return {
|
||||
required: stringArrayField(record, 'required'),
|
||||
excluded: stringArrayField(record, 'excluded'),
|
||||
requiredSince,
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the leading date of a `yyyy-mm-dd-*.md` basename, if present. */
|
||||
export function datedDocumentDate(file: string): string | undefined {
|
||||
return DATED_DOCUMENT.exec(file)?.[1]
|
||||
}
|
||||
|
||||
/** Whether a date-named document falls on or after the pairing cutoff. */
|
||||
export function requiresPairByDate(file: string, requiredSince: string): boolean {
|
||||
const date = datedDocumentDate(file)
|
||||
return date !== undefined && date >= requiredSince
|
||||
}
|
||||
|
||||
/** The structural surface compared between the two sides of a pair. */
|
||||
export interface TranslationStructureSignature {
|
||||
/** Heading depths in document order (h2 -> 2). */
|
||||
headings: number[]
|
||||
/** Fenced code blocks verbatim: info string plus content, in order. */
|
||||
code: string[]
|
||||
/** Row and column count of each table, in order. */
|
||||
tables: string[]
|
||||
/** Kind, ordered-list start, and direct item count of each list, in order. */
|
||||
lists: string[]
|
||||
/** Every link target in order; the language switcher is excluded. */
|
||||
links: string[]
|
||||
}
|
||||
|
||||
/** Parse Markdown with the same GFM extensions used by the pairing gate. */
|
||||
export function parseTranslationMarkdown(content: string): Nodes {
|
||||
return fromMarkdown(content, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
|
||||
}
|
||||
|
||||
/** Whether the tree contains a link to exactly `target`. */
|
||||
export function linksTo(tree: Nodes, target: string): boolean {
|
||||
let found = false
|
||||
const visit = (node: Nodes): void => {
|
||||
if (node.type === 'link' && node.url === target) found = true
|
||||
if ('children' in node) for (const child of node.children) visit(child)
|
||||
}
|
||||
visit(tree)
|
||||
return found
|
||||
}
|
||||
|
||||
/** Collect the ordered structural signature, skipping one switcher target. */
|
||||
export function translationStructureSignature(tree: Nodes, switcherTarget: string): TranslationStructureSignature {
|
||||
const sig: TranslationStructureSignature = { headings: [], code: [], tables: [], lists: [], links: [] }
|
||||
const visit = (node: Nodes): void => {
|
||||
switch (node.type) {
|
||||
case 'heading':
|
||||
sig.headings.push(node.depth)
|
||||
break
|
||||
case 'code':
|
||||
sig.code.push(`\`\`\`${node.lang ?? ''}${node.meta ? ` ${node.meta}` : ''}\n${node.value}`)
|
||||
break
|
||||
case 'table':
|
||||
sig.tables.push(`${node.children.length}x${node.children[0]?.children.length ?? 0}`)
|
||||
break
|
||||
case 'list':
|
||||
sig.lists.push(node.ordered
|
||||
? `ordered:start=${node.start ?? 1}:items=${node.children.length}`
|
||||
: `bullet:items=${node.children.length}`)
|
||||
break
|
||||
case 'link':
|
||||
if (node.url !== switcherTarget) sig.links.push(node.url)
|
||||
break
|
||||
default:
|
||||
// Every other node kind is prose or a container, not part of the signature.
|
||||
break
|
||||
}
|
||||
if ('children' in node) for (const child of node.children) visit(child)
|
||||
}
|
||||
visit(tree)
|
||||
return sig
|
||||
}
|
||||
|
||||
/** Render a signature element for an error message, truncated for readability. */
|
||||
function show(value: string | number | undefined): string {
|
||||
if (value === undefined) return 'nothing'
|
||||
const text = JSON.stringify(value)
|
||||
return text.length > 72 ? `${text.slice(0, 72)}…` : text
|
||||
}
|
||||
|
||||
/** Return the first divergence for each structural field; empty means equal. */
|
||||
export function translationStructureDiff(
|
||||
source: TranslationStructureSignature,
|
||||
zh: TranslationStructureSignature,
|
||||
): string[] {
|
||||
const out: string[] = []
|
||||
const fields: [string, (string | number)[], (string | number)[]][] = [
|
||||
['heading (depth)', source.headings, zh.headings],
|
||||
['code block', source.code, zh.code],
|
||||
['table (row x column count)', source.tables, zh.tables],
|
||||
['list (kind, start, item count)', source.lists, zh.lists],
|
||||
['link target', source.links, zh.links],
|
||||
]
|
||||
for (const [field, sourceValues, zhValues] of fields) {
|
||||
const length = Math.max(sourceValues.length, zhValues.length)
|
||||
for (let index = 0; index < length; index++) {
|
||||
if (sourceValues[index] !== zhValues[index]) {
|
||||
out.push(`${field} #${index + 1} diverges between the pair: ${show(sourceValues[index])} vs ${show(zhValues[index])}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/** Regression tests for the executable translation prompt contract. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
parseTranslationResponse,
|
||||
renderTranslationPrompt,
|
||||
renderTranslationResponse,
|
||||
} from './translation-prompt.ts'
|
||||
|
||||
const document = `# Wrapper
|
||||
|
||||
## 模板正文
|
||||
|
||||
\`\`\`\`text
|
||||
{{source_lang}} to {{target_lang}}
|
||||
{{translation_rules}}
|
||||
{{terminology}}
|
||||
[English]({{source_filename}}) | [中文]({{source_filename_zh}})
|
||||
\`\`\`\`
|
||||
`
|
||||
|
||||
describe('translation prompt rendering', () => {
|
||||
it('renders every supported placeholder without recursively rewriting injected rules', () => {
|
||||
const rendered = renderTranslationPrompt(document, {
|
||||
sourceLanguage: 'English',
|
||||
sourceFilename: 'guide.md',
|
||||
translationRules: 'A literal {{source_lang}} in injected rules.',
|
||||
terminology: '| English | 中文 |',
|
||||
})
|
||||
expect(rendered).toContain('English to Chinese')
|
||||
expect(rendered).toContain('A literal {{source_lang}} in injected rules.')
|
||||
expect(rendered).toContain('[English](guide.md) | [中文](guide.zh.md)')
|
||||
})
|
||||
|
||||
it('rejects a filename whose suffix contradicts the source language', () => {
|
||||
expect(() => renderTranslationPrompt(document, {
|
||||
sourceLanguage: 'Chinese',
|
||||
sourceFilename: 'guide.md',
|
||||
translationRules: 'rules',
|
||||
terminology: 'terms',
|
||||
})).toThrow('does not match source language Chinese')
|
||||
})
|
||||
|
||||
it('rejects malformed template placeholders before injecting rule contents', () => {
|
||||
expect(() => renderTranslationPrompt(document.replace('{{source_lang}}', '{{source-lang}}'), {
|
||||
sourceLanguage: 'English',
|
||||
sourceFilename: 'guide.md',
|
||||
translationRules: 'A literal {{source_lang}} in injected rules.',
|
||||
terminology: '| English | 中文 |',
|
||||
})).toThrow('template contains malformed placeholder syntax')
|
||||
})
|
||||
})
|
||||
|
||||
describe('translation response XML', () => {
|
||||
it('round-trips Markdown and the CDATA terminator', () => {
|
||||
const response = {
|
||||
translation: '# Draft\n\nA ]]> marker.',
|
||||
review: '- [Tone] Fixed.',
|
||||
final: '# Final\n\nA ]]> marker.',
|
||||
}
|
||||
expect(parseTranslationResponse(renderTranslationResponse(response))).toEqual(response)
|
||||
})
|
||||
|
||||
it('rejects missing, reordered, nested, attributed, or non-CDATA children', () => {
|
||||
expect(() => parseTranslationResponse('<dsh-translation-response version="1"/>')).toThrow('translation, review, and final')
|
||||
expect(() => parseTranslationResponse('<dsh-translation-response version="1"><review><![CDATA[x]]></review></dsh-translation-response>'))
|
||||
.toThrow('expected translation, got review')
|
||||
expect(() => parseTranslationResponse(renderTranslationResponse({ translation: 'x', review: 'y', final: 'z' })
|
||||
.replace('<translation><![CDATA[x]]></translation>', '<translation><b><![CDATA[x]]></b></translation>')))
|
||||
.toThrow('nested element b is not allowed')
|
||||
expect(() => parseTranslationResponse(renderTranslationResponse({ translation: 'x', review: 'y', final: 'z' }).replace('<review>', '<review lang="en">')))
|
||||
.toThrow('review must not have attributes')
|
||||
expect(() => parseTranslationResponse(renderTranslationResponse({ translation: 'x', review: 'y', final: 'z' }).replace('<![CDATA[x]]>', 'x')))
|
||||
.toThrow('all response field content must be inside CDATA')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Executable renderer and strict response parser for the committed
|
||||
* documentation-translation prompt contract.
|
||||
*/
|
||||
|
||||
import { basename } from 'node:path'
|
||||
import { SaxesParser } from 'saxes'
|
||||
|
||||
/** Placeholder names supported by the committed translation prompt. */
|
||||
export const TRANSLATION_PROMPT_PLACEHOLDERS = [
|
||||
'source_lang',
|
||||
'target_lang',
|
||||
'translation_rules',
|
||||
'terminology',
|
||||
'source_filename',
|
||||
'source_filename_zh',
|
||||
] as const
|
||||
|
||||
type TranslationPromptPlaceholder = (typeof TRANSLATION_PROMPT_PLACEHOLDERS)[number]
|
||||
|
||||
/** Languages accepted by the bidirectional prompt. */
|
||||
type TranslationLanguage = 'English' | 'Chinese'
|
||||
|
||||
/** Inputs that vary for one rendered translation request. */
|
||||
export interface TranslationPromptInput {
|
||||
sourceLanguage: TranslationLanguage
|
||||
/** Source basename, including `.md` or `.zh.md`. */
|
||||
sourceFilename: string
|
||||
/** Complete current `translation-rules.md` contents. */
|
||||
translationRules: string
|
||||
/** Complete current `terminology.md` contents. */
|
||||
terminology: string
|
||||
}
|
||||
|
||||
/** Parsed contents of the three-element XML response. */
|
||||
export interface TranslationResponse {
|
||||
translation: string
|
||||
review: string
|
||||
final: string
|
||||
}
|
||||
|
||||
const PLACEHOLDER = /{{([a-z_]+)}}/g
|
||||
const TEMPLATE_OPEN = '## 模板正文\n\n````text\n'
|
||||
const TEMPLATE_CLOSE = '\n````'
|
||||
const RESPONSE_CHILDREN = ['translation', 'review', 'final'] as const
|
||||
|
||||
/** Extract the machine-consumed text fence from `translation-prompt.md`. */
|
||||
function extractTranslationPrompt(document: string): string {
|
||||
const start = document.indexOf(TEMPLATE_OPEN)
|
||||
if (start === -1) throw new Error('translation prompt: missing `## 模板正文` text fence')
|
||||
const contentStart = start + TEMPLATE_OPEN.length
|
||||
const end = document.indexOf(TEMPLATE_CLOSE, contentStart)
|
||||
if (end === -1) throw new Error('translation prompt: missing closing four-backtick fence')
|
||||
return document.slice(contentStart, end)
|
||||
}
|
||||
|
||||
/** Read the placeholder names documented in the prompt's contract table. */
|
||||
export function documentedTranslationPromptPlaceholders(document: string): string[] {
|
||||
const preambleEnd = document.indexOf(TEMPLATE_OPEN)
|
||||
if (preambleEnd === -1) throw new Error('translation prompt: missing template body')
|
||||
return [...document.slice(0, preambleEnd).matchAll(/^\| `{{([a-z_]+)}}` \|/gm)].map(match => match[1] ?? '')
|
||||
}
|
||||
|
||||
/** Render one system prompt from the checked-in template and canonical rules. */
|
||||
export function renderTranslationPrompt(document: string, input: TranslationPromptInput): string {
|
||||
if (basename(input.sourceFilename) !== input.sourceFilename) {
|
||||
throw new Error(`translation prompt: sourceFilename must be a basename; got ${JSON.stringify(input.sourceFilename)}`)
|
||||
}
|
||||
const sourceIsChinese = input.sourceFilename.endsWith('.zh.md')
|
||||
if (input.sourceLanguage === 'Chinese' ? !sourceIsChinese : sourceIsChinese || !input.sourceFilename.endsWith('.md')) {
|
||||
throw new Error(`translation prompt: ${input.sourceFilename} does not match source language ${input.sourceLanguage}`)
|
||||
}
|
||||
|
||||
const targetLanguage: TranslationLanguage = input.sourceLanguage === 'English' ? 'Chinese' : 'English'
|
||||
const sourceFilenameZh = sourceIsChinese ? input.sourceFilename : input.sourceFilename.replace(/\.md$/, '.zh.md')
|
||||
const values: Record<TranslationPromptPlaceholder, string> = {
|
||||
source_lang: input.sourceLanguage,
|
||||
target_lang: targetLanguage,
|
||||
translation_rules: input.translationRules,
|
||||
terminology: input.terminology,
|
||||
source_filename: input.sourceFilename,
|
||||
source_filename_zh: sourceFilenameZh,
|
||||
}
|
||||
const template = extractTranslationPrompt(document)
|
||||
const placeholderFreeTemplate = template.replace(PLACEHOLDER, '')
|
||||
if (placeholderFreeTemplate.includes('{{') || placeholderFreeTemplate.includes('}}')) {
|
||||
throw new Error('translation prompt: template contains malformed placeholder syntax')
|
||||
}
|
||||
const names = [...template.matchAll(PLACEHOLDER)].map(match => match[1] ?? '')
|
||||
const unknown = names.filter(name => !TRANSLATION_PROMPT_PLACEHOLDERS.includes(name as TranslationPromptPlaceholder))
|
||||
if (unknown.length > 0) throw new Error(`translation prompt: unsupported placeholder(s): ${[...new Set(unknown)].join(', ')}`)
|
||||
const missing = TRANSLATION_PROMPT_PLACEHOLDERS.filter(name => !names.includes(name))
|
||||
if (missing.length > 0) throw new Error(`translation prompt: template does not use required placeholder(s): ${missing.join(', ')}`)
|
||||
|
||||
return template.replace(PLACEHOLDER, (_token, name: string) => values[name as TranslationPromptPlaceholder])
|
||||
}
|
||||
|
||||
/** Escape one value so it remains byte-identical inside an XML CDATA field. */
|
||||
function escapeTranslationCdata(value: string): string {
|
||||
return value.replaceAll(']]>', ']]]]><![CDATA[>')
|
||||
}
|
||||
|
||||
/** Serialize a response using the exact XML wire contract in the prompt. */
|
||||
export function renderTranslationResponse(response: TranslationResponse): string {
|
||||
return [
|
||||
'<dsh-translation-response version="1">',
|
||||
`<translation><![CDATA[${escapeTranslationCdata(response.translation)}]]></translation>`,
|
||||
`<review><![CDATA[${escapeTranslationCdata(response.review)}]]></review>`,
|
||||
`<final><![CDATA[${escapeTranslationCdata(response.final)}]]></final>`,
|
||||
'</dsh-translation-response>',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Parse and validate the exact XML response shape emitted by the model. */
|
||||
export function parseTranslationResponse(xml: string): TranslationResponse {
|
||||
const values: TranslationResponse = { translation: '', review: '', final: '' }
|
||||
const stack: string[] = []
|
||||
const cdataFields = new Set<string>()
|
||||
let rootSeen = false
|
||||
let childIndex = 0
|
||||
const fail = (message: string): never => {
|
||||
throw new Error(`translation response: ${message}`)
|
||||
}
|
||||
const parser = new SaxesParser({ xmlns: false })
|
||||
|
||||
parser.on('opentag', (tag) => {
|
||||
if (stack.length === 0) {
|
||||
if (rootSeen) fail('contains more than one root element')
|
||||
if (tag.name !== 'dsh-translation-response') fail(`expected dsh-translation-response root, got ${tag.name}`)
|
||||
const attributes = Object.keys(tag.attributes)
|
||||
if (attributes.length !== 1 || tag.attributes.version !== '1') fail('root must have only version="1"')
|
||||
rootSeen = true
|
||||
} else if (stack.length === 1) {
|
||||
const expected = RESPONSE_CHILDREN[childIndex]
|
||||
if (tag.name !== expected) fail(`expected ${expected ?? 'no more children'}, got ${tag.name}`)
|
||||
if (Object.keys(tag.attributes).length !== 0) fail(`${tag.name} must not have attributes`)
|
||||
childIndex++
|
||||
} else {
|
||||
fail(`nested element ${tag.name} is not allowed`)
|
||||
}
|
||||
stack.push(tag.name)
|
||||
})
|
||||
parser.on('text', (value) => {
|
||||
if (stack.length <= 1 && value.trim() === '') return
|
||||
fail('all response field content must be inside CDATA')
|
||||
})
|
||||
parser.on('cdata', (value) => {
|
||||
const field = stack.at(-1)
|
||||
if (field === undefined || !RESPONSE_CHILDREN.includes(field as (typeof RESPONSE_CHILDREN)[number])) {
|
||||
fail('CDATA is allowed only inside translation, review, or final')
|
||||
}
|
||||
const key = field as (typeof RESPONSE_CHILDREN)[number]
|
||||
values[key] += value
|
||||
cdataFields.add(key)
|
||||
})
|
||||
parser.on('closetag', (tag) => {
|
||||
const expected = stack.pop()
|
||||
if (expected !== tag.name) fail(`closing ${tag.name} does not match ${expected ?? 'nothing'}`)
|
||||
})
|
||||
parser.on('comment', () => fail('comments are not allowed'))
|
||||
parser.on('doctype', () => fail('doctypes are not allowed'))
|
||||
parser.on('processinginstruction', () => fail('processing instructions are not allowed'))
|
||||
parser.on('error', error => fail(`invalid XML: ${error.message}`))
|
||||
parser.write(xml).close()
|
||||
|
||||
if (childIndex !== RESPONSE_CHILDREN.length) fail('translation, review, and final must each appear exactly once and in order')
|
||||
for (const field of RESPONSE_CHILDREN) {
|
||||
if (!cdataFields.has(field)) fail(`${field} must contain a CDATA section`)
|
||||
}
|
||||
return values
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Shared TypeScript Program construction for repository gates that need real
|
||||
* cross-file symbols and types instead of isolated syntax trees.
|
||||
*/
|
||||
|
||||
import { relative, resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
|
||||
interface ProjectGraph {
|
||||
rootNames: string[]
|
||||
options: ts.CompilerOptions
|
||||
}
|
||||
|
||||
const configHost: ts.ParseConfigFileHost = {
|
||||
useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames,
|
||||
readDirectory: (...args) => ts.sys.readDirectory(...args),
|
||||
fileExists: fileName => ts.sys.fileExists(fileName),
|
||||
readFile: fileName => ts.sys.readFile(fileName),
|
||||
getCurrentDirectory: () => ts.sys.getCurrentDirectory(),
|
||||
onUnRecoverableConfigFileDiagnostic(diagnostic) {
|
||||
throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))
|
||||
},
|
||||
}
|
||||
|
||||
/** Parse a root tsconfig and flatten all referenced projects into one semantic graph. */
|
||||
function loadProjectGraph(projectRoot: string): ProjectGraph {
|
||||
const rootConfigPath = resolve(projectRoot, 'tsconfig.json')
|
||||
const rootConfig = parseConfig(rootConfigPath)
|
||||
const rootNames = new Set<string>()
|
||||
const visited = new Set<string>()
|
||||
|
||||
const collect = (configPath: string, parsed: ts.ParsedCommandLine): void => {
|
||||
if (visited.has(configPath)) return
|
||||
visited.add(configPath)
|
||||
for (const fileName of parsed.fileNames) rootNames.add(fileName)
|
||||
for (const reference of parsed.projectReferences ?? []) {
|
||||
const referencePath = ts.resolveProjectReferencePath(reference)
|
||||
collect(referencePath, parseConfig(referencePath))
|
||||
}
|
||||
}
|
||||
collect(rootConfigPath, rootConfig)
|
||||
|
||||
return {
|
||||
rootNames: [...rootNames],
|
||||
options: rootConfig.options,
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse one config file and fail loud on any config diagnostic. */
|
||||
function parseConfig(configPath: string): ts.ParsedCommandLine {
|
||||
const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, configHost)
|
||||
if (!parsed) throw new Error(`cannot parse TypeScript config ${configPath}`)
|
||||
if (parsed.errors.length > 0) {
|
||||
throw new Error(parsed.errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n'))
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
/** Disable emit-only options after loading the root solution config. */
|
||||
function semanticCompilerOptions(options: ts.CompilerOptions): ts.CompilerOptions {
|
||||
return {
|
||||
...options,
|
||||
noEmit: true,
|
||||
composite: false,
|
||||
declaration: false,
|
||||
declarationMap: false,
|
||||
sourceMap: false,
|
||||
incremental: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** A repository-scoped TypeScript Program and its shared TypeChecker. */
|
||||
export class TypeScriptProject {
|
||||
/** The bound cross-file TypeScript program. */
|
||||
readonly program: ts.Program
|
||||
/** The checker shared by every semantic query in this project. */
|
||||
readonly checker: ts.TypeChecker
|
||||
|
||||
constructor(private readonly projectRoot: string) {
|
||||
const graph = loadProjectGraph(projectRoot)
|
||||
this.program = ts.createProgram(graph.rootNames, semanticCompilerOptions(graph.options))
|
||||
this.checker = this.program.getTypeChecker()
|
||||
}
|
||||
|
||||
/**
|
||||
* Return every source file loaded into the flattened root project graph.
|
||||
* @returns program source files, including libraries and external dependencies.
|
||||
*/
|
||||
sourceFiles(): readonly ts.SourceFile[] {
|
||||
return this.program.getSourceFiles()
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a loaded source file relative to the project root.
|
||||
* @param sourceFile - a source file from this project.
|
||||
* @returns a slash-separated repository-relative path.
|
||||
*/
|
||||
relativePath(sourceFile: ts.SourceFile): string {
|
||||
return relative(this.projectRoot, sourceFile.fileName).replaceAll('\\', '/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Return one program source file by repository-relative path.
|
||||
* @param relativePath - path relative to the project root.
|
||||
* @returns the source file bound into this project.
|
||||
* @throws if a requested root or imported source was not loaded.
|
||||
*/
|
||||
sourceFile(relativePath: string): ts.SourceFile {
|
||||
const sourceFile = this.program.getSourceFile(resolve(this.projectRoot, relativePath))
|
||||
if (!sourceFile) throw new Error(`TypeScript project did not load ${relativePath}`)
|
||||
return sourceFile
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,17 @@
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "HookContext", "source": "packages/core/agent/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "PromptDecision", "source": "packages/core/agent/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationDecision", "source": "packages/core/agent/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationStop", "source": "packages/core/agent/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "SessionStartSource", "source": "packages/core/agent/src/types.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/scope.md", "symbol": "ScopeKey", "source": "packages/core/scope/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/scope.md", "symbol": "Scoped", "source": "packages/core/scope/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/scope.md", "symbol": "Scope", "source": "packages/core/scope/src/index.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/system-prompt.md", "symbol": "AssembleContext", "source": "packages/core/system-prompt/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/system-prompt.md", "symbol": "PromptSection", "source": "packages/core/system-prompt/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/system-prompt.md", "symbol": "ToolProviderResult", "source": "packages/core/system-prompt/src/index.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "StreamChunk", "source": "packages/llm/llm/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "TokenUsage", "source": "packages/llm/llm/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" },
|
||||
@@ -31,15 +40,28 @@
|
||||
{ "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceOp", "source": "packages/core/session/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceIntent", "source": "packages/core/session/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceNode", "source": "packages/core/session/src/surface.ts" },
|
||||
{ "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceFoldReplacement", "source": "packages/core/session/src/surface.ts" },
|
||||
{ "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceFoldResult", "source": "packages/core/session/src/surface.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSurface", "source": "packages/session-query/session-query/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionRecord", "source": "packages/session-query/session-query/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventRecord", "source": "packages/session-query/session-query/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionQueryErrorCode", "source": "packages/session-query/session-query/src/config.ts" },
|
||||
{ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventReadRequest", "source": "packages/session-query/session-query/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventWindow", "source": "packages/session-query/session-query/src/types.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolDefinition", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaProp", "source": "packages/core/tools/src/schema.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaSpec", "source": "packages/core/tools/src/schema.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "InferArgs", "source": "packages/core/tools/src/schema.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionToken", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionInput", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolGuard", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolRestriction", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionResult", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "PreToolDecision", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "PostToolDecision", "source": "packages/core/tools/src/index.ts" },
|
||||
@@ -56,18 +78,36 @@
|
||||
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "UserInteractionProvider", "source": "packages/ui/user-interaction/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "UserInteractionError", "source": "packages/ui/user-interaction/src/index.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/approval.md", "symbol": "ApprovalRequestId", "source": "packages/ui/user-approval/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/approval.md", "symbol": "ApprovalOutcome", "source": "packages/ui/user-approval/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/approval.md", "symbol": "ApprovalPolicy", "source": "packages/ui/user-approval/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/approval.md", "symbol": "ApprovalRequest", "source": "packages/ui/user-approval/src/index.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecRequest", "source": "packages/bash/bash/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecSpec", "source": "packages/bash/bash/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashRunResult", "source": "packages/bash/bash/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashSandboxInfo", "source": "packages/bash/bash/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "CollectedOutput", "source": "packages/bash/bash/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashTask", "source": "packages/bash/bash/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashProcess", "source": "packages/bash/bash/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashProcessRead", "source": "packages/bash/bash/src/types.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskKindMap", "source": "packages/tasks/tasks/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskStart", "source": "packages/tasks/tasks/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskHooks", "source": "packages/tasks/tasks/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskOutcome", "source": "packages/tasks/tasks/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskSnapshot", "source": "packages/tasks/tasks/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/tasks.md", "symbol": "TaskRead", "source": "packages/tasks/tasks/src/types.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/sandbox.md", "symbol": "SandboxMode", "source": "packages/sandbox/sandbox/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/sandbox.md", "symbol": "ConfinedSandboxMode", "source": "packages/sandbox/sandbox/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/sandbox.md", "symbol": "SandboxEnforcement", "source": "packages/sandbox/sandbox/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/sandbox.md", "symbol": "SandboxPolicy", "source": "packages/sandbox/sandbox/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/sandbox.md", "symbol": "ConfinedArgv", "source": "packages/sandbox/sandbox/src/index.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunRequest", "source": "packages/code-runtime/code-runtime/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunResult", "source": "packages/code-runtime/code-runtime/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeBindingNamespace", "source": "packages/code-runtime/code-runtime/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeBindingFunction", "source": "packages/code-runtime/code-runtime/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeLogEntry", "source": "packages/code-runtime/code-runtime/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunFailure", "source": "packages/code-runtime/code-runtime/src/types.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTarget", "source": "packages/fs/fs/src/types.ts" },
|
||||
@@ -83,6 +123,16 @@
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsPolicyExec", "source": "packages/fs/fs-policy/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileReadOutcome", "source": "packages/fs/tool-fs/src/read-render.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/skills.md", "symbol": "SkillSource", "source": "packages/skill/skill/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/skills.md", "symbol": "SkillResourceBase", "source": "packages/skill/skill/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/skills.md", "symbol": "SkillSummary", "source": "packages/skill/skill/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/skills.md", "symbol": "SkillCandidate", "source": "packages/skill/skill/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/skills.md", "symbol": "SkillDefinition", "source": "packages/skill/skill/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/skills.md", "symbol": "SkillRegistration", "source": "packages/skill/skill/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/skills.md", "symbol": "SkillLookupOptions", "source": "packages/skill/skill/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/skills.md", "symbol": "SkillProvider", "source": "packages/skill/skill/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/skills.md", "symbol": "Config", "source": "packages/skill/skill/src/index.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/compaction.md", "symbol": "CompactionResult", "source": "packages/compact/compact/src/types.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentCapabilities", "source": "packages/subagent/subagent/src/types.ts" },
|
||||
@@ -98,7 +148,6 @@
|
||||
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchRequest", "source": "packages/web/web/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchResult", "source": "packages/web/web/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchBody", "source": "packages/web/web/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebProviderStatus", "source": "packages/web/web/src/types.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowStartRequest", "source": "packages/workflow/workflow/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowMeta", "source": "packages/workflow/workflow/src/types.ts" },
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Reject JavaScript expressions in Cordis Loader entry metadata.
|
||||
*
|
||||
* The Loader interpolates only a plugin entry's `config`; expression objects in
|
||||
* fields such as `disabled` remain truthy data and silently change composition.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import * as yaml from 'js-yaml'
|
||||
|
||||
interface JsExpr {
|
||||
__jsExpr: 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', {
|
||||
kind: 'scalar',
|
||||
resolve: data => typeof data === 'string',
|
||||
construct: (data: unknown): JsExpr => {
|
||||
if (typeof data !== 'string') throw new TypeError('!!js requires a scalar string')
|
||||
return { __jsExpr: data }
|
||||
},
|
||||
})
|
||||
const schema = yaml.JSON_SCHEMA.extend(jsExprType)
|
||||
|
||||
const files = globSync(['**/*cordis*.yml', '**/*cordis*.yaml'], {
|
||||
cwd: root,
|
||||
exclude: ['.claude/**', 'node_modules/**', 'vendor/**'],
|
||||
}).sort()
|
||||
const errors: string[] = []
|
||||
|
||||
for (const file of files) {
|
||||
const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema })
|
||||
if (!isUnknownArray(document)) {
|
||||
errors.push(`${file}: root must be a Loader entry array`)
|
||||
continue
|
||||
}
|
||||
for (let index = 0; index < document.length; index++) {
|
||||
validateEntry(document[index], file, `[${index}]`)
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error('verify-cordis-config: Loader entry metadata is static; move !!js under plugin config or select an explicit overlay.')
|
||||
for (const error of errors) console.error(`- ${error}`)
|
||||
process.exitCode = 1
|
||||
} else {
|
||||
console.log(`verify-cordis-config: ${files.length} config files passed.`)
|
||||
}
|
||||
|
||||
function validateEntry(value: unknown, file: string, path: string): void {
|
||||
if (!isRecord(value)) {
|
||||
errors.push(`${file}${path}: entry must be an object`)
|
||||
return
|
||||
}
|
||||
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++) {
|
||||
validateEntry(value.config[index], file, `${path}.config[${index}]`)
|
||||
}
|
||||
}
|
||||
if (value.name !== '@cordisjs/plugin-include') return
|
||||
const config = value.config
|
||||
if (!isRecord(config) || !isUnknownArray(config.patches)) return
|
||||
for (let index = 0; index < config.patches.length; index++) {
|
||||
const patch = config.patches[index]
|
||||
const patchPath = `${path}.config.patches[${index}]`
|
||||
if (!isRecord(patch)) continue
|
||||
validateMetadata(patch, file, patchPath)
|
||||
if (!isUnknownArray(patch.insert)) continue
|
||||
for (let insertIndex = 0; insertIndex < patch.insert.length; insertIndex++) {
|
||||
validateEntry(patch.insert[insertIndex], file, `${patchPath}.insert[${insertIndex}]`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateMetadata(entry: Record<string, unknown>, file: string, path: string): void {
|
||||
for (const field of metadataFields) {
|
||||
if (!(field in entry)) continue
|
||||
const expressionPaths: string[] = []
|
||||
collectExpressionPaths(entry[field], `${path}.${field}`, expressionPaths)
|
||||
for (const expressionPath of expressionPaths) errors.push(`${file}${expressionPath}: !!js is not interpolated here`)
|
||||
}
|
||||
}
|
||||
|
||||
function collectExpressionPaths(value: unknown, path: string, output: string[]): void {
|
||||
if (isJsExpr(value)) {
|
||||
output.push(path)
|
||||
return
|
||||
}
|
||||
if (isUnknownArray(value)) {
|
||||
for (let index = 0; index < value.length; index++) collectExpressionPaths(value[index], `${path}[${index}]`, output)
|
||||
return
|
||||
}
|
||||
if (!isRecord(value)) return
|
||||
for (const [key, child] of Object.entries(value)) collectExpressionPaths(child, `${path}.${key}`, output)
|
||||
}
|
||||
|
||||
function isJsExpr(value: unknown): value is JsExpr {
|
||||
return isRecord(value) && typeof value.__jsExpr === 'string'
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object'
|
||||
}
|
||||
|
||||
function isUnknownArray(value: unknown): value is unknown[] {
|
||||
return Array.isArray(value)
|
||||
}
|
||||
@@ -1,30 +1,9 @@
|
||||
/**
|
||||
* Doc-sync gate: enforce word-count ceilings on the standing docs that accrete
|
||||
* (docs/AGENTS.md § "Budgets and the ceiling gate"). Instruction files and the
|
||||
* architecture overview grow a paragraph per PR unless something pushes back;
|
||||
* this gate is the pushback — when a ceiling is hit, the fix is to relocate or
|
||||
* condense per the documentation standard, not to raise the ceiling. Raising a
|
||||
* ceiling is allowed but is a deliberate, reviewable manifest diff that the PR
|
||||
* description must justify.
|
||||
*
|
||||
* Scope is deliberately NARROW: only the files listed in
|
||||
* scripts/doc-budgets.manifest.json (path → max words). Reference docs, RFCs,
|
||||
* and package READMEs are unbudgeted — length is legitimate there (a feature
|
||||
* matrix is the right kind of long), and the standard governs them through
|
||||
* review, not a ceiling.
|
||||
*
|
||||
* The manifest is an enforcement frontier, i18n-rollout style: a ceiling sits
|
||||
* at least 5% above the doc's current size (working headroom, so routine
|
||||
* wording edits pass while real growth trips the gate) and ratchets DOWN,
|
||||
* keeping that margin, as the doc is brought to its target budget. A manifest entry whose file is missing
|
||||
* fails the gate, so a rename cannot silently orphan its budget.
|
||||
*
|
||||
* Words are counted `wc -w` style over the whole file (whitespace-delimited
|
||||
* tokens, fenced code included) so a ceiling is reproducible with standard
|
||||
* tools. This is a checker, not a formatter: it reports and never rewrites.
|
||||
*
|
||||
* Run: `tsx scripts/verify-doc-budgets.ts` (or `--list` to print every
|
||||
* budgeted doc's current count vs ceiling without failing).
|
||||
* Enforce `wc -w`-style ceilings from `scripts/doc-budgets.manifest.json`.
|
||||
* Missing files and invalid ceilings fail; `--list` reports current usage.
|
||||
* Only listed standing docs are budgeted. Ceilings ratchet down with at least
|
||||
* 5% headroom; raising one requires the justification defined in
|
||||
* `docs/AGENTS.md`.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
|
||||
+11
-65
@@ -1,33 +1,12 @@
|
||||
/**
|
||||
* Doc-sync gate: verify that doc references written in TypeScript COMMENTS
|
||||
* resolve to a file that exists. Source comments cite docs by root-relative
|
||||
* prose path — `see docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md`,
|
||||
* `docs/architecture.md § Where New Behavior Goes`. `verify-md-links` parses Markdown
|
||||
* link AST and never sees these, so a doc rename or move could silently orphan
|
||||
* a `.ts` comment that points at it. The RFC classification reorg
|
||||
* ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md))
|
||||
* is the motivating case: it moved every RFC under a `{class}/` folder, and
|
||||
* several `.ts` doc comments cite RFC paths that changed.
|
||||
*
|
||||
* Detection is a token scan, NOT an AST walk: doc refs live in free prose inside
|
||||
* comments, not in a structured form. We match `docs/<path>.md` tokens and
|
||||
* REQUIRE the `.md` extension, so extensionless prose (`docs/postmortem/0001`,
|
||||
* `docs/architecture.md § Where New Behavior Goes` — the section suffix is outside the
|
||||
* token) is left alone rather than misread as a path. Each token is resolved
|
||||
* ROOT-RELATIVE (the way the comments are written) and must exist on disk. This
|
||||
* is checker, not fixer: it reports and never rewrites.
|
||||
*
|
||||
* Scope is repo-authored TypeScript under `packages/**` and `examples/**`,
|
||||
* excluding built output (`lib/`, `*.d.ts`) and `vendor/` (pinned upstream
|
||||
* source we do not own). The scan is purely textual, so it does not distinguish
|
||||
* a token in a comment from one in a string literal — a `docs/….md` string in
|
||||
* code is checked too, which is harmless (such a path should resolve anyway).
|
||||
*
|
||||
* Run: `tsx scripts/verify-doc-refs.ts`.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { existsSync, globSync, readFileSync } from 'node:fs'
|
||||
import { relative, resolve } from 'node:path'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { findReferenceViolations, uniqueRepoFiles, type ReferenceViolation as Violation } from './repo-files.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
@@ -38,50 +17,17 @@ const PATTERNS = ['packages/**/*.ts', 'examples/**/*.ts']
|
||||
const isExcluded = (p: string): boolean =>
|
||||
p.includes('/lib/') || p.endsWith('.d.ts') || p.startsWith('vendor/')
|
||||
|
||||
/**
|
||||
* Match a `docs/….md` reference token. The `.md` extension is required so a
|
||||
* bare `docs/postmortem/0001` (no extension) does not register as a path. The
|
||||
* character class stops at whitespace, backticks, parens, and the section sign,
|
||||
* so trailing prose (`… .md § Where New Behavior Goes`) is not swallowed into the path.
|
||||
*/
|
||||
/** Root-relative Markdown path token, excluding trailing prose. */
|
||||
const DOC_REF = /\bdocs\/[A-Za-z0-9._/-]+\.md/g
|
||||
|
||||
/** A broken doc reference: a root-relative `docs/….md` token with no file. */
|
||||
interface Violation {
|
||||
file: string
|
||||
/** 1-based line where the reference appears. */
|
||||
line: number
|
||||
ref: string
|
||||
}
|
||||
|
||||
/** Find every broken `docs/….md` reference in one TypeScript file. */
|
||||
function findViolations(absPath: string): Violation[] {
|
||||
const file = relative(root, absPath)
|
||||
const source = readFileSync(absPath, 'utf8')
|
||||
const out: Violation[] = []
|
||||
const lines = source.split('\n')
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (line === undefined) continue
|
||||
for (const m of line.matchAll(DOC_REF)) {
|
||||
const ref = m[0]
|
||||
if (!existsSync(resolve(root, ref))) {
|
||||
out.push({ file, line: i + 1, ref })
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
return findReferenceViolations(root, absPath, DOC_REF, ref => ref, ref => !existsSync(resolve(root, ref)))
|
||||
}
|
||||
|
||||
const all: Violation[] = []
|
||||
let checked = 0
|
||||
for (const pattern of PATTERNS) {
|
||||
for (const match of globSync(pattern, { cwd: root })) {
|
||||
if (isExcluded(match)) continue
|
||||
checked++
|
||||
all.push(...findViolations(resolve(root, match)))
|
||||
}
|
||||
}
|
||||
const files = uniqueRepoFiles(root, PATTERNS, isExcluded)
|
||||
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.`)
|
||||
|
||||
+123
-149
@@ -1,81 +1,14 @@
|
||||
/**
|
||||
* Verify JSDoc completeness for EVERY module-level exported name of every
|
||||
* non-vendored package (each `packages/<group>/<pkg>/src/` tree). This is the
|
||||
* mechanical form of the AGENTS.md rule "every export has a JSDoc explaining
|
||||
* semantics", generalizing the cordis-surface gate (`gen-cordis-catalog.ts`,
|
||||
* which owns `interface Events` members and `ctx.<key>` service classes) to
|
||||
* the whole export surface; the parsing + check helpers are shared via
|
||||
* `scripts/jsdoc.ts` so "documented" means the same thing on both.
|
||||
*
|
||||
* `tsx scripts/verify-export-jsdoc.ts` → exit 1 listing every offender
|
||||
*
|
||||
* The contract, per exported declaration kind:
|
||||
*
|
||||
* - Every exported name needs JSDoc with non-empty description prose (prose
|
||||
* ends at the first block tag, standard JSDoc semantics).
|
||||
* - A function-like export (function declaration, a const with a function
|
||||
* initializer or an INLINE callable annotation, or a non-identifier
|
||||
* function default export) additionally needs a non-empty `@param` per
|
||||
* parameter (`this` receiver annotations exempt; a stale `@param` errors)
|
||||
* and a non-empty `@returns` unless the return type is `void` /
|
||||
* `Promise<void>`. Wrapper expressions (parentheses, `as` / `satisfies`
|
||||
* casts, non-null assertions) are peeled before classifying. The walk
|
||||
* classifies returns syntactically, so the return type must be ANNOTATED —
|
||||
* except a const whose declarator is annotated with a NAMED type (e.g.
|
||||
* `export const f: Handler = …`), where that type's own declaration owns
|
||||
* the signature contract and `@returns` stays optional; an inline
|
||||
* `(x: T) => U` annotation or single-call-signature literal is the surface
|
||||
* signature itself and gets the full contract, and a literal mixing
|
||||
* call/construct signatures with anything else is refused (extract a named
|
||||
* type).
|
||||
* - An exported class needs class-level JSDoc; its public methods (static
|
||||
* included — they are reachable on the exported name) follow the function
|
||||
* contract, and public properties and accessors need description prose (on
|
||||
* a get/set pair the getter's doc covers both). A member declared by an
|
||||
* `extends`/`implements` heritage type is EXEMPT — the seam declaration is
|
||||
* the doc's one home, the IDE inherits it, and re-documenting every
|
||||
* implementation invites drift — UNLESS the override grows surface the
|
||||
* base never documented: a protected-only base member does not exempt a
|
||||
* public override, parameters the base never names keep their `@param`
|
||||
* duty, and a concrete result above a void base return keeps its
|
||||
* `@returns` duty. Heritage members (and classifying an unannotated
|
||||
* override's inferred return above a void base) are the questions the walk
|
||||
* asks the TYPE CHECKER; everything else is pure AST.
|
||||
* Constructors are exempt like the cordis gate's: plugin classes are
|
||||
* framework-constructed, and the class doc owns the story.
|
||||
* - Exported interfaces, type aliases, enums: description prose on the
|
||||
* declaration (member-level docs stay review's job; the highest-value
|
||||
* member surface — seam service classes — is already under the cordis
|
||||
* gate).
|
||||
* - An exported namespace recurses (its exported members are package
|
||||
* surface; in an ambient `declare` namespace every member exports
|
||||
* implicitly); the namespace itself needs prose only when it does not
|
||||
* merge with an already-documented same-name declaration (the
|
||||
* Config-namespace idiom documents the class/function once, not twice).
|
||||
* - The cordis plugin-protocol slots are exempt: top-level `name` / `inject`
|
||||
* / `reusable` / `Config` consts and the `apply` entry, plus the same
|
||||
* slots as statics on a plugin class. Their shape is fixed by the
|
||||
* framework, so a doc would restate the protocol — the module doc comment
|
||||
* and the `interface Config` carry the plugin's real semantics. (These
|
||||
* names are reserved by cordis convention; documenting one anyway is
|
||||
* allowed, only absence goes unchecked.)
|
||||
* - Overload groups: each overload signature carries its own docs; the
|
||||
* implementation signature is exempt (callers never see it).
|
||||
* - Skipped: `declare module` / `declare global` augmentation bodies (the
|
||||
* cordis gate's turf; an augmentation is not an export of the package) and
|
||||
* re-export statements with a module specifier (`export … from`) — the
|
||||
* defining module is walked on its own, and external definitions are not
|
||||
* ours to document. An `export import X = N.member` alias documents
|
||||
* ITSELF, and only prose-only target kinds are gate-supported: a callable,
|
||||
* class, or namespace target carries signature/member contracts the alias
|
||||
* cannot hold and is refused (export the declaration directly).
|
||||
* - Everything else fails CLOSED: `export =` is refused outright, and an
|
||||
* exported statement kind the dispatch does not recognize is itself a
|
||||
* violation, so no export form can pass unchecked by omission.
|
||||
* Enforce JSDoc on every non-vendored package export. Functions and public
|
||||
* class methods require parameter and non-void return documentation; exported
|
||||
* declarations require description prose. Inline callable types, overload
|
||||
* signatures, namespace members, and public class members are included;
|
||||
* framework slots, constructors, inherited contracts, augmentations, and source
|
||||
* 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'
|
||||
|
||||
@@ -141,12 +74,8 @@ function unwrapExpression(e: ts.Expression): ts.Expression {
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a declarator's type annotation for the function contract: an
|
||||
* inline function type or a type literal that is EXACTLY one call signature
|
||||
* is the surface signature itself; a literal mixing call/construct
|
||||
* signatures with anything else cannot be classified syntactically and is
|
||||
* refused (fail closed — extract a named type); everything else is a plain
|
||||
* value shape.
|
||||
* Classify inline callable annotations. Mixed callable literals fail closed;
|
||||
* other annotations are ordinary value shapes.
|
||||
* @param type - the declarator's type annotation.
|
||||
* @returns the signature to check, 'refuse' for an unclassifiable callable literal, or null for a non-callable shape.
|
||||
*/
|
||||
@@ -162,29 +91,12 @@ function callableAnnotation(type: ts.TypeNode): ts.SignatureDeclarationBase | 'r
|
||||
}
|
||||
|
||||
/**
|
||||
* The heritage-member exemption for one class member. When the member's name
|
||||
* is declared by an `extends`/`implements` heritage type, the seam declaration
|
||||
* is the doc's one home (the IDE inherits it on hover) and the member needs no
|
||||
* doc of its own — EXCEPT where the override grows public surface the base
|
||||
* never documented: a base member that is protected on every declaration does
|
||||
* not exempt a public override (consumers could not call it before);
|
||||
* parameters the base never names keep their own `@param` duty (the caller
|
||||
* reads the seam doc, which cannot describe them; an underscore-prefixed
|
||||
* rename of a base parameter — the deliberately-unused marker — is the same
|
||||
* parameter, not new surface); and a void base return carried no `@returns`
|
||||
* duty, so an override returning a concrete result documents it itself.
|
||||
* Static members are looked up on the base CONSTRUCTOR type (only an
|
||||
* `extends` expression has one; an unresolvable or interface expression
|
||||
* yields no property and therefore no exemption).
|
||||
* Find inherited documentation for a class member without exempting newly public surface.
|
||||
* @param cls - the class whose heritage to search.
|
||||
* @param name - the member name to look up.
|
||||
* @param staticSide - whether to search the constructor side instead of the instance side.
|
||||
* @param checker - the program's type checker.
|
||||
* @returns null when no exemption applies; otherwise the parameter names the
|
||||
* base declarations carry (`baseParams: null` when not syntactically
|
||||
* recoverable — a complex heritage type — exempting all parameters) plus
|
||||
* whether every recoverable base return annotation is `void`-like
|
||||
* (`baseVoidReturn: null` when none is recoverable, exempting the result).
|
||||
* @returns inherited parameter and return coverage, or `null` when none applies.
|
||||
*/
|
||||
function heritageExemption(
|
||||
cls: ts.ClassDeclaration,
|
||||
@@ -326,11 +238,8 @@ function checkClass(cls: ts.ClassDeclaration, name: string, w: Walk): void {
|
||||
checkParams(where, 'export', m.parameters, parseTags(raw).params, w.sf,
|
||||
p => thisReceiver(p) || inBase(p), w.violations)
|
||||
}
|
||||
// A void base return carried no @returns duty, so an override growing
|
||||
// a concrete result documents it itself. An annotated override runs
|
||||
// the standard check; an inferred one is classified by the checker
|
||||
// (this branch is already the checker's domain), so a faithful void
|
||||
// override stays exempt without a boilerplate annotation.
|
||||
// A void base return carried no @returns duty, so an override growing a concrete result
|
||||
// documents it itself.
|
||||
if (exemption.baseVoidReturn === true) {
|
||||
if (m.type !== undefined) {
|
||||
checkReturns(where, m.type, parseTags(raw).returns, w.sf, w.violations)
|
||||
@@ -354,20 +263,14 @@ function checkClass(cls: ts.ClassDeclaration, name: string, w: Walk): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check one exported declaration statement, dispatching on its kind. Any
|
||||
* exported statement kind the dispatch does not recognize is a violation
|
||||
* (fail closed), so no export form can pass unchecked by omission.
|
||||
* @param stmt - the exported statement (export modifier or export-list target).
|
||||
* @param prefix - the namespace qualification for surface names ('' at top level).
|
||||
* @param overloadSigs - names in this scope declared as bodyless function overload signatures.
|
||||
* @param byName - this scope's named declarations (for namespace/sibling-merge lookups).
|
||||
* @param ambient - whether the enclosing scope is ambient (`declare`), where members export implicitly.
|
||||
* @param w - the walk state violations append to.
|
||||
* @param only - for a multi-declarator variable statement reached through an
|
||||
* export list (or a default-export identifier), the declarator names that
|
||||
* are actually exported; `null` means the whole statement is surface
|
||||
* (direct `export` modifier or ambient scope). Non-variable statements
|
||||
* declare exactly one name, so the filter never applies to them.
|
||||
* Check one exported declaration.
|
||||
* @param stmt - exported statement.
|
||||
* @param prefix - namespace qualifier.
|
||||
* @param overloadSigs - bodyless overload names.
|
||||
* @param byName - declarations keyed by name.
|
||||
* @param ambient - whether exports are implicit.
|
||||
* @param w - walk state.
|
||||
* @param only - selected declarators, or all.
|
||||
*/
|
||||
function checkDecl(
|
||||
stmt: ts.Statement,
|
||||
@@ -455,13 +358,9 @@ function checkDecl(
|
||||
}
|
||||
if (ts.isImportEqualsDeclaration(stmt)) {
|
||||
const where = `exported alias '${prefix}${stmt.name.text}'${at(stmt)}`
|
||||
// An alias is a distinct exported name whose target may be a non-exported
|
||||
// namespace member no walk ever visits, so it documents ITSELF — which
|
||||
// matches the gate's strength only for prose-only target kinds. A
|
||||
// callable, class, or namespace target carries signature or member
|
||||
// contracts the alias prose cannot hold: refuse those (fail closed) and
|
||||
// demand the declaration be exported directly. An unresolvable target is
|
||||
// refused for the same reason.
|
||||
// An alias is a distinct exported name whose target may be a non-exported namespace member
|
||||
// no walk ever visits, so it documents ITSELF — which matches the gate's strength only for
|
||||
// prose-only target kinds.
|
||||
const sym = w.checker.getSymbolAtLocation(stmt.name)
|
||||
const target = sym !== undefined && (sym.flags & ts.SymbolFlags.Alias) !== 0 ? w.checker.getAliasedSymbol(sym) : sym
|
||||
const RICH_TARGETS = ts.SymbolFlags.Function | ts.SymbolFlags.Class | ts.SymbolFlags.ValueModule | ts.SymbolFlags.NamespaceModule
|
||||
@@ -490,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 => {
|
||||
@@ -511,17 +416,7 @@ function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk
|
||||
}
|
||||
}
|
||||
}
|
||||
// Two-phase dispatch. Phase one accumulates WHICH statements are surface
|
||||
// and, for a variable statement reached by name (an export list or a
|
||||
// default-export identifier), which of its declarators the exports actually
|
||||
// name — `null` marks the whole statement as surface (a direct `export`
|
||||
// modifier, or an ambient scope). Requests for the same statement merge:
|
||||
// `null` absorbs any name set, and name sets union, so
|
||||
// `export { a }; export { b }` over one `const a = …, b = …` checks both
|
||||
// declarators while a never-exported sibling stays out of the surface.
|
||||
// Phase two runs each surfaced statement exactly once. (Checking a
|
||||
// statement eagerly per request would either re-check on the second list or
|
||||
// — deduplicated — silently drop the second list's declarators.)
|
||||
// Two-phase dispatch.
|
||||
const requested = new Map<ts.Statement, Set<string> | null>()
|
||||
const request = (stmt: ts.Statement, name: string | null): void => {
|
||||
const prior = requested.get(stmt)
|
||||
@@ -566,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)
|
||||
@@ -574,15 +482,70 @@ 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. The real repo hands over its
|
||||
* tsconfig.base.json (whose `paths` map resolves cross-package imports to
|
||||
* source, so heritage-member lookups see seam types); a fixture root without
|
||||
* one gets `noLib` + no `@types` — fixtures are single-file and
|
||||
* self-contained, nothing in the walk resolves a lib symbol, and default-lib
|
||||
* parsing is ~99% of per-program cost (it made the fixture spec time out
|
||||
* under CI coverage instrumentation). Emit-side options are stripped: the
|
||||
* walk never emits or asks for diagnostics, it only binds types on demand.
|
||||
* Compiler options for the walk's program.
|
||||
*
|
||||
* @param scanRoot - the root being scanned.
|
||||
* @returns compiler options for ts.createProgram.
|
||||
*/
|
||||
@@ -612,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
|
||||
}
|
||||
|
||||
+14
-61
@@ -1,50 +1,19 @@
|
||||
/**
|
||||
* Doc-sync gate: verify that every relative Markdown cross-link resolves to a
|
||||
* file that exists. Docs in this repo link to each other by relative path
|
||||
* (`[topic](../implemented/2026-…-….md)`, `[the cookbook](adding-a-tool.md)`);
|
||||
* a rename or a move silently breaks those links, and nothing caught it before
|
||||
* review. The RFC tree reorganization (one `docs/rfc/` with proposed/
|
||||
* implemented/ rejected/ subfolders, every file renamed to a dated slug) is the
|
||||
* motivating case: ~40 inter-doc links were rewritten by hand, and a single
|
||||
* fat-fingered path would have shipped a dead link.
|
||||
*
|
||||
* Detection is AST-based, mirroring verify-md-wrap: parse each file with
|
||||
* mdast-util-from-markdown + GFM, then walk every `link`, `image`, and
|
||||
* `definition` node. A target is checked when it is a RELATIVE path; these are
|
||||
* skipped because they are not ours to verify:
|
||||
* - absolute URLs with a scheme (`https:`, `http:`, `mailto:`, …),
|
||||
* - protocol-relative URLs (`//host/path`),
|
||||
* - root-absolute paths (`/foo` — no stable base in a repo checkout),
|
||||
* - pure in-page anchors (`#section`).
|
||||
* For a relative target the `#fragment` and `?query` are stripped, the path is
|
||||
* resolved against the linking file's directory, and the result must exist on
|
||||
* disk. This is checker, not fixer: it reports and never rewrites.
|
||||
*
|
||||
* Scope is the other doc-sync gates' set plus example Markdown, AGENTS.md
|
||||
* files in those checked trees, AND the repo-authored agent-skill Markdown under
|
||||
* `.agents/skills/` — those skill files cross-link into the docs tree (e.g. the
|
||||
* dsh-code-review skill cites the RFC index), so a rename must not silently
|
||||
* break them either: README.md, docs/** /*.md, packages/* /README.md,
|
||||
* examples/** /*.md, AGENTS.md, packages/AGENTS.md, .agents/skills/** /*.md.
|
||||
* The root, packages/, and examples/ CLAUDE.md files are symlinks to the
|
||||
* AGENTS.md files, so they are deduped by real path.
|
||||
*
|
||||
* Run: `tsx scripts/verify-md-links.ts`.
|
||||
* Verify that relative Markdown links, images, and definitions resolve. URL,
|
||||
* root-absolute, and in-page targets are excluded; query strings and fragments
|
||||
* do not affect resolution against the source file. The checker never rewrites,
|
||||
* and symlinked instruction files are deduped.
|
||||
*/
|
||||
|
||||
import { existsSync, globSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { dirname, relative, resolve } 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 { parseMarkdown, visitMarkdown } from './markdown.ts'
|
||||
import { uniqueRepoFiles } from './repo-files.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
/**
|
||||
* Files to check: doc-typecheck's scope, example Markdown, the AGENTS.md pair,
|
||||
* and repo-authored agent-skill Markdown.
|
||||
*/
|
||||
/** Repo-authored Markdown checked for relative links. */
|
||||
const PATTERNS = [
|
||||
'README.md',
|
||||
'README.zh.md',
|
||||
@@ -103,7 +72,7 @@ function findViolations(absPath: string): Violation[] {
|
||||
const file = relative(root, absPath)
|
||||
const dir = dirname(absPath)
|
||||
const source = readFileSync(absPath, 'utf8')
|
||||
const tree = fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
|
||||
const tree = parseMarkdown(source)
|
||||
const out: Violation[] = []
|
||||
|
||||
const check = (url: string, node: Nodes): void => {
|
||||
@@ -117,33 +86,17 @@ function findViolations(absPath: string): Violation[] {
|
||||
}
|
||||
}
|
||||
|
||||
const visit = (node: Nodes): void => {
|
||||
visitMarkdown(tree, (node: Nodes): void => {
|
||||
if ((node.type === 'link' || node.type === 'image' || node.type === 'definition') && 'url' in node) {
|
||||
check(node.url, node)
|
||||
}
|
||||
if ('children' in node) {
|
||||
for (const child of node.children) visit(child)
|
||||
}
|
||||
}
|
||||
visit(tree)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
const all: Violation[] = []
|
||||
let checked = 0
|
||||
for (const pattern of PATTERNS) {
|
||||
for (const match of globSync(pattern, { cwd: root })) {
|
||||
const abs = resolve(root, match)
|
||||
// CLAUDE.md symlinks resolve onto AGENTS.md; dedupe by real path so a file
|
||||
// matched twice (or via symlink) is checked once.
|
||||
const real = realpathSync(abs)
|
||||
if (seen.has(real)) continue
|
||||
seen.add(real)
|
||||
checked++
|
||||
all.push(...findViolations(abs))
|
||||
}
|
||||
}
|
||||
const files = uniqueRepoFiles(root, PATTERNS)
|
||||
const all = files.flatMap(file => findViolations(file.abs))
|
||||
const checked = files.length
|
||||
|
||||
if (all.length === 0) {
|
||||
console.log(`verify-md-links: ${checked} file(s) checked, all relative cross-links resolve.`)
|
||||
|
||||
+27
-55
@@ -1,41 +1,30 @@
|
||||
/**
|
||||
* Doc-sync gate: enforce the repo's "Markdown is not hard-wrapped" convention
|
||||
* (docs/AGENTS.md § Writing rules) — prose paragraphs are written as
|
||||
* one physical line per paragraph and the editor soft-wraps. A hard-wrapped
|
||||
* paragraph (a one-word edit reflows and re-diffs the whole block) is a defect
|
||||
* this script catches before review.
|
||||
*
|
||||
* Detection is AST-based: we parse each file with mdast-util-from-markdown (the
|
||||
* CommonMark parser behind remark) plus the GFM extension, then flag any
|
||||
* `paragraph` node whose source span covers more than one line. The parser owns
|
||||
* all the structure that legitimately occupies multiple lines — fenced code
|
||||
* (any fence length), tables, list items, blockquotes, HTML blocks, headings,
|
||||
* thematic breaks, link-reference definitions — so a hard wrap is simply "a
|
||||
* paragraph node that starts and ends on different lines." This is checker, not
|
||||
* formatter: it reports and never rewrites, so it introduces zero cosmetic
|
||||
* churn (no emphasis-marker or table-delimiter normalization).
|
||||
*
|
||||
* A wrapped paragraph inside a list item or blockquote is still a `paragraph`
|
||||
* node, so those are caught too. Scope mirrors doc-typecheck plus the two
|
||||
* AGENTS.md files that doc-sync does NOT otherwise cover (the convention itself
|
||||
* lives there): README.md, docs/** /*.md, packages/* /*.md, AGENTS.md,
|
||||
* packages/AGENTS.md. The root and packages/ CLAUDE.md are symlinks to the
|
||||
* AGENTS.md files, so they are deduped by real path.
|
||||
*
|
||||
* Run: `tsx scripts/verify-md-wrap.ts`.
|
||||
* 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`.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { relative, resolve } 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 { parseMarkdown, visitMarkdown } from './markdown.ts'
|
||||
import { uniqueRepoFiles } from './repo-files.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
/** Files to check: doc-typecheck's scope plus the AGENTS.md pair. */
|
||||
const PATTERNS = ['README.md', 'README.zh.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'AGENTS.md', 'packages/AGENTS.md']
|
||||
/** Files to check: doc-typecheck's scope, prompt goldens, and the AGENTS.md pair. */
|
||||
const PATTERNS = [
|
||||
'README.md',
|
||||
'README.zh.md',
|
||||
'docs/**/*.md',
|
||||
'packages/*/*.md',
|
||||
'packages/*/*/*.md',
|
||||
'examples/**/system-prompt.golden.md',
|
||||
'packages/**/system-prompt.golden.md',
|
||||
'AGENTS.md',
|
||||
'packages/AGENTS.md',
|
||||
]
|
||||
|
||||
/** A located hard-wrap: a prose paragraph spanning more than one source line. */
|
||||
interface Violation {
|
||||
@@ -49,43 +38,26 @@ interface Violation {
|
||||
function findViolations(absPath: string): Violation[] {
|
||||
const file = relative(root, absPath)
|
||||
const source = readFileSync(absPath, 'utf8')
|
||||
const tree = fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
|
||||
const tree = parseMarkdown(source)
|
||||
const out: Violation[] = []
|
||||
|
||||
const visit = (node: Nodes): void => {
|
||||
visitMarkdown(tree, (node: Nodes): boolean | void => {
|
||||
if (node.type === 'paragraph' && node.position) {
|
||||
const { start, end } = node.position
|
||||
if (end.line > start.line) {
|
||||
const firstLine = source.split('\n')[start.line - 1] ?? ''
|
||||
out.push({ file, line: start.line, text: firstLine.trim() })
|
||||
}
|
||||
// A paragraph's children are inline (text/emphasis/…); no nested
|
||||
// paragraphs to find, so don't descend.
|
||||
return
|
||||
// Paragraph children are inline, so no further paragraph can be nested.
|
||||
return false
|
||||
}
|
||||
if ('children' in node) {
|
||||
for (const child of node.children) visit(child)
|
||||
}
|
||||
}
|
||||
visit(tree)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
const all: Violation[] = []
|
||||
let checked = 0
|
||||
for (const pattern of PATTERNS) {
|
||||
for (const match of globSync(pattern, { cwd: root })) {
|
||||
const abs = resolve(root, match)
|
||||
// CLAUDE.md symlinks resolve onto AGENTS.md; dedupe by real path so a file
|
||||
// matched twice (or via symlink) is checked once.
|
||||
const real = realpathSync(abs)
|
||||
if (seen.has(real)) continue
|
||||
seen.add(real)
|
||||
checked++
|
||||
all.push(...findViolations(abs))
|
||||
}
|
||||
}
|
||||
const files = uniqueRepoFiles(root, PATTERNS)
|
||||
const all = files.flatMap(file => findViolations(file.abs))
|
||||
const checked = files.length
|
||||
|
||||
if (all.length === 0) {
|
||||
console.log(`verify-md-wrap: ${checked} file(s) checked, no hard-wrapped prose paragraphs.`)
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
/**
|
||||
* Doc-sync gate: verify every fenced ```mermaid block parses with Mermaid's
|
||||
* own parser. Markdown link/type/code gates can say a diagram block exists and
|
||||
* is linked, but only Mermaid can catch syntax errors that GitHub would fail to
|
||||
* render.
|
||||
*
|
||||
* Scope matches the Markdown link gate so any Mermaid diagram in repo-authored
|
||||
* docs is checked: README.md, README.zh.md, docs/** /*.md,
|
||||
* packages/* /*.md, packages/* /* /*.md, examples/** /*.md, AGENTS.md,
|
||||
* packages/AGENTS.md, and .agents/skills/** /*.md.
|
||||
*
|
||||
* Run: `tsx scripts/verify-mermaid.ts`.
|
||||
* Parse every repo-authored Mermaid fence with Mermaid itself, catching syntax that link and fence
|
||||
* checks cannot. Scope intentionally matches the Markdown link gate, including standing docs,
|
||||
* package/example docs, and agent skills. Run with `tsx scripts/verify-mermaid.ts`.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync, realpathSync } from 'node:fs'
|
||||
|
||||
@@ -143,7 +143,11 @@ try {
|
||||
.join('\n')
|
||||
writeFileSync(resolve(tmp, 'index.ts'), `${imports}\n`)
|
||||
|
||||
execFileSync(resolve(root, 'node_modules/.bin/tsc'), ['-p', resolve(tmp, 'tsconfig.json'), '--pretty', 'false'], {
|
||||
// tsc's JS entry via 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) — a hazard for the
|
||||
// temp tsconfig path. The JS entry behaves identically on every platform.
|
||||
execFileSync(process.execPath, ['node_modules/typescript/bin/tsc', '-p', resolve(tmp, 'tsconfig.json'), '--pretty', 'false'], {
|
||||
cwd: root,
|
||||
stdio: 'pipe',
|
||||
})
|
||||
|
||||
@@ -1,46 +1,13 @@
|
||||
/**
|
||||
* Doc-sync gate: catch DRIFTED `packages/<path>` references — a path to a
|
||||
* package that has MOVED, written as prose in Markdown or in a TypeScript
|
||||
* comment/string. Docs and comments cite package locations by root-relative
|
||||
* path (`packages/core/tools/src/index.ts`, `see packages/ui/acp`);
|
||||
* `verify-md-links` only parses Markdown LINK targets and `verify-doc-refs`
|
||||
* only checks `docs/*.md` tokens, so a `packages/…` path sitting in backtick
|
||||
* prose or a code comment goes unchecked. The package-hierarchy reorg is the
|
||||
* motivating case: it moved every package under a `{group}/` folder, so a stale
|
||||
* `packages/tools` (now `packages/core/tools`) reads fine to a human but points
|
||||
* at nothing.
|
||||
*
|
||||
* The check is drift-scoped, NOT a blanket existence test: a broken
|
||||
* `packages/<path>` token is a violation ONLY when one of its path segments is
|
||||
* the directory name of a package that actually exists on disk — i.e. the
|
||||
* package is real and the path is merely stale. A token naming a package that
|
||||
* exists NOWHERE (`packages/code-runtime` in a forward-looking proposal, an
|
||||
* illustrative `packages/<name>/` skeleton) is left alone: this gate reports
|
||||
* MOVED paths, not hypothetical or future ones, so it applies uniformly to
|
||||
* proposed/implemented/rejected docs without per-lifecycle exclusions. This is
|
||||
* checker, not fixer: it reports and never rewrites.
|
||||
*
|
||||
* Detection is a token scan, NOT an AST walk: package refs live in free prose,
|
||||
* backticks, and comments. We match `packages/<path>` tokens whose path is made
|
||||
* of plain path characters, so a glob, a `<placeholder>`, or a `{brace,expansion}`
|
||||
* terminates the match before those chars and is never probed.
|
||||
*
|
||||
* Scope mirrors the other doc gates plus repo-authored TypeScript: Markdown
|
||||
* across README/docs/packages/AGENTS, and `.ts` under packages/** and
|
||||
* examples/** (excluding built `lib/`, `*.d.ts`, and vendored upstream source).
|
||||
* A reference to a package's build OUTPUT (`packages/<group>/<pkg>/lib/…`,
|
||||
* e.g. `packages/ui/acp-agent/lib/bin.js` cited by a built-bin smoke) is also
|
||||
* skipped — it is emitted only by `pnpm run build`, which CI runs AFTER this
|
||||
* gate, so flagging it would be a false positive on a path that is correct but
|
||||
* not yet on disk. That skip is scoped to a REAL package root: a stale
|
||||
* group-less `packages/acp-agent/lib/bin.js` is still flagged (its root does not
|
||||
* exist — exactly the moved-package drift this gate catches).
|
||||
*
|
||||
* Run: `tsx scripts/verify-package-paths.ts`.
|
||||
* Find stale root-relative `packages/...` references in repo-authored prose and
|
||||
* TypeScript. A missing path is reported only when it names a real package leaf;
|
||||
* globs, placeholders, hypothetical packages, and unbuilt `lib/` output are
|
||||
* outside the check.
|
||||
*/
|
||||
|
||||
import { existsSync, globSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { relative, resolve } from 'node:path'
|
||||
import { existsSync, readdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { findReferenceViolations, uniqueRepoFiles, type ReferenceViolation as Violation } from './repo-files.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
@@ -89,70 +56,32 @@ const packageNames = realPackageNames()
|
||||
*/
|
||||
const PKG_REF = /\bpackages\/[A-Za-z0-9._/-]+/g
|
||||
|
||||
/** A broken package reference: a stale root-relative `packages/…` path. */
|
||||
interface Violation {
|
||||
file: string
|
||||
/** 1-based line where the reference appears. */
|
||||
line: number
|
||||
ref: string
|
||||
function isDriftedPackageReference(ref: string): boolean {
|
||||
if (existsSync(resolve(root, ref))) return false
|
||||
// Ignore unbuilt `lib/` paths only under an existing depth-two package root:
|
||||
// CI runs this gate before build, while stale group-less paths must still fail.
|
||||
const parts = ref.split('/')
|
||||
const libAt = parts.indexOf('lib')
|
||||
if (libAt === 3 && existsSync(resolve(root, parts.slice(0, 3).join('/')))) return false
|
||||
// A missing reference is drift only when a path segment names a live package.
|
||||
return ref.split('/').slice(1).some(segment => packageNames.has(segment))
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every DRIFTED `packages/…` reference in one file: a token that does not
|
||||
* resolve on disk AND names a real package in one of its segments (so it is a
|
||||
* moved path, not a typo or a not-yet-existing package). The same real-package
|
||||
* test also screens out a bare `packages` (no segment) and illustrative
|
||||
* skeletons whose segment is not a package.
|
||||
*/
|
||||
/** Find missing package references whose path names a live package; bare paths, typos, and illustrative skeletons do not count. */
|
||||
function findViolations(absPath: string): Violation[] {
|
||||
const file = relative(root, absPath)
|
||||
const source = readFileSync(absPath, 'utf8')
|
||||
const out: Violation[] = []
|
||||
const lines = source.split('\n')
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (line === undefined) continue
|
||||
for (const m of line.matchAll(PKG_REF)) {
|
||||
// Trim a trailing path separator or sentence punctuation that the greedy
|
||||
// class may have swallowed (`packages/core/tools.` / `…/tools/`).
|
||||
const ref = m[0].replace(/[./]+$/, '')
|
||||
if (existsSync(resolve(root, ref))) continue
|
||||
// A reference INTO a package's built `lib/` is a build OUTPUT, not an
|
||||
// authored-source location: it does not exist until `pnpm run build` emits
|
||||
// it, and CI runs this gate BEFORE the build step. Skip it — but ONLY when
|
||||
// the `packages/<group>/<pkg>` ROOT it sits under is real and on disk, so
|
||||
// `packages/ui/acp-agent/lib/bin.js` (correct, just not yet built) is
|
||||
// exempt while a stale `packages/acp-agent/lib/bin.js` (group-less, the
|
||||
// exact moved-package drift this gate exists to catch) still flags. A bare
|
||||
// `lib` segment is not a blanket escape hatch.
|
||||
const parts = ref.split('/')
|
||||
const libAt = parts.indexOf('lib')
|
||||
if (libAt === 3 && existsSync(resolve(root, parts.slice(0, 3).join('/')))) continue
|
||||
// Only a stale path to a REAL (moved) package is a violation; a segment
|
||||
// matching a live package name is the drift signal.
|
||||
const segments = ref.split('/').slice(1)
|
||||
if (segments.some(seg => packageNames.has(seg))) {
|
||||
out.push({ file, line: i + 1, ref })
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
return findReferenceViolations(
|
||||
root,
|
||||
absPath,
|
||||
PKG_REF,
|
||||
// Remove trailing separators or sentence punctuation matched greedily.
|
||||
ref => ref.replace(/[./]+$/, ''),
|
||||
isDriftedPackageReference,
|
||||
)
|
||||
}
|
||||
|
||||
const all: Violation[] = []
|
||||
let checked = 0
|
||||
const seen = new Set<string>()
|
||||
for (const pattern of PATTERNS) {
|
||||
for (const match of globSync(pattern, { cwd: root })) {
|
||||
if (isExcluded(match)) continue
|
||||
// Dedup by real path: the root/packages CLAUDE.md are symlinks to AGENTS.md.
|
||||
const real = realpathSync(resolve(root, match))
|
||||
if (seen.has(real)) continue
|
||||
seen.add(real)
|
||||
checked++
|
||||
all.push(...findViolations(real))
|
||||
}
|
||||
}
|
||||
const files = uniqueRepoFiles(root, PATTERNS, isExcluded)
|
||||
const all = files.flatMap(file => findViolations(file.real))
|
||||
const checked = files.length
|
||||
|
||||
if (all.length === 0) {
|
||||
console.log(`verify-package-paths: ${checked} file(s) checked, all packages/* references resolve.`)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
|
||||
import { existsSync, globSync, readFileSync } from 'node:fs'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import { markdownHeadingLines, markdownProseLines } from './markdown.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
/** The one canonical section heading, required verbatim as an h2. */
|
||||
const CANONICAL = '## Known Limitations and Deferred Work'
|
||||
|
||||
/** Packages audited as having no limitations section, keyed by repo-relative directory. */
|
||||
const NO_LIMITATIONS: Readonly<Record<string, string>> = {
|
||||
'packages/util/brand': 'Type-only nominal-branding primitive with no runtime behavior or deferred work.',
|
||||
}
|
||||
|
||||
/** A heading that reads as a limitations section — canonical or drifted. */
|
||||
function isLimitationsLike(headingText: string): boolean {
|
||||
return (
|
||||
/\blimitations?\b/i.test(headingText)
|
||||
|| /deferred work/i.test(headingText)
|
||||
|| /what is not here/i.test(headingText)
|
||||
|| /^deferred\b/i.test(headingText)
|
||||
|| /^non-goals?\b/i.test(headingText)
|
||||
)
|
||||
}
|
||||
|
||||
const packageJsons = globSync('packages/*/*/package.json', { cwd: root }).map(path => path.split(sep).join('/')).sort()
|
||||
const scannedPackages = new Set(packageJsons.map(path => path.slice(0, -'/package.json'.length)))
|
||||
const failures: string[] = []
|
||||
|
||||
for (const [entry, reason] of Object.entries(NO_LIMITATIONS)) {
|
||||
if (!scannedPackages.has(entry)) {
|
||||
failures.push(`whitelist entry ${entry} does not name a scanned package — renamed or removed? update NO_LIMITATIONS in scripts/verify-package-readme-limitations.ts in the same change`)
|
||||
}
|
||||
if (reason.trim().length === 0) {
|
||||
failures.push(`whitelist entry ${entry} has no justification — state why a limitations section would be empty boilerplate`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const pkg of scannedPackages) {
|
||||
const readme = `${pkg}/README.md`
|
||||
if (!existsSync(resolve(root, readme))) {
|
||||
failures.push(`${readme}: package manifest has no sibling README with the \`${CANONICAL}\` section`)
|
||||
continue
|
||||
}
|
||||
const source = readFileSync(resolve(root, readme), 'utf8')
|
||||
const lines = markdownProseLines(source)
|
||||
const headings = markdownHeadingLines(source)
|
||||
const limitations = headings.filter(heading => isLimitationsLike(heading.text))
|
||||
|
||||
if (Object.hasOwn(NO_LIMITATIONS, pkg)) {
|
||||
for (const heading of limitations) {
|
||||
failures.push(`${readme}:${heading.index}: whitelisted as having no known limitations, but carries ${JSON.stringify(heading.raw)} — drop the section or remove the package from NO_LIMITATIONS`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const heading = limitations.at(0)
|
||||
if (heading === undefined) {
|
||||
failures.push(`${readme}: missing the \`${CANONICAL}\` section (a package with genuinely nothing to declare joins NO_LIMITATIONS in scripts/verify-package-readme-limitations.ts instead)`)
|
||||
continue
|
||||
}
|
||||
if (limitations.length > 1) {
|
||||
failures.push(`${readme}: ${limitations.length} limitations-like headings (lines ${limitations.map(line => line.index).join(', ')}) — keep exactly one \`${CANONICAL}\` section`)
|
||||
continue
|
||||
}
|
||||
if (heading.depth !== 2 || heading.raw.trimEnd() !== CANONICAL) {
|
||||
failures.push(`${readme}:${heading.index}: non-canonical heading ${JSON.stringify(heading.raw)} — use \`${CANONICAL}\``)
|
||||
continue
|
||||
}
|
||||
const headingAt = lines.findIndex(line => line.index === heading.index)
|
||||
const body = lines.slice(headingAt + 1)
|
||||
const headingLines = new Set(headings.map(entry => entry.index))
|
||||
const end = body.findIndex(line => headingLines.has(line.index))
|
||||
const section = end === -1 ? body : body.slice(0, end)
|
||||
if (!section.some(line => /^- /.test(line.raw))) {
|
||||
failures.push(`${readme}:${heading.index}: the \`${CANONICAL}\` section has no top-level \`- \` bullet — state the limitations, or whitelist the package if there are genuinely none`)
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('verify-package-readme-limitations: violations found:')
|
||||
for (const failure of failures) console.error(` ${failure}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`verify-package-readme-limitations: ${scannedPackages.size} package READMEs checked (${Object.keys(NO_LIMITATIONS).length} whitelisted), all conform.`)
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
|
||||
import { existsSync, globSync, readFileSync } from 'node:fs'
|
||||
import { relative, resolve, sep } from 'node:path'
|
||||
import { markdownHeadingLines, markdownProseLines, type MarkdownProseLine } from './markdown.ts'
|
||||
|
||||
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**'
|
||||
|
||||
type SentenceKind = 'none' | 'indirect'
|
||||
|
||||
interface SentenceContract {
|
||||
kind: SentenceKind
|
||||
reason: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic packages whose public contract is model-agnostic. Their READMEs omit
|
||||
* Model Experience entirely; the reason stays here as reviewable audit evidence
|
||||
* so an absent section cannot be mistaken for forgotten documentation.
|
||||
*/
|
||||
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 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.
|
||||
*/
|
||||
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/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/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' },
|
||||
'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' },
|
||||
'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' },
|
||||
'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/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/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/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/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.' },
|
||||
'packages/workflow/workflow': { kind: 'indirect', reason: 'The service delegates parent and child model rendering to its consumer and engine.' },
|
||||
}
|
||||
|
||||
interface Failure {
|
||||
path: string
|
||||
message: string
|
||||
}
|
||||
|
||||
type Line = MarkdownProseLine
|
||||
|
||||
interface ContextSurface {
|
||||
heading: Line
|
||||
modelView: Line
|
||||
tokenEffect: Line
|
||||
title: string
|
||||
verbatimBlocks: number
|
||||
}
|
||||
|
||||
/** Validate H4-plus-markdown literals nested after one context surface's fields. */
|
||||
function validateNestedVerbatim(raw: readonly 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' }
|
||||
}
|
||||
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 (fragments.has(fragment)) {
|
||||
return { blocks, error: `verbatim H4 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' }
|
||||
}
|
||||
cursor += 1
|
||||
const contentStart = cursor
|
||||
while (cursor < raw.length && raw[cursor] !== '```') cursor += 1
|
||||
if (cursor === raw.length) return { blocks, error: 'unterminated nested ```markdown fence' }
|
||||
if (cursor === contentStart) return { blocks, error: 'nested ```markdown fence must not be empty' }
|
||||
cursor += 1
|
||||
blocks += 1
|
||||
}
|
||||
return { blocks }
|
||||
}
|
||||
|
||||
/** GitHub-style fragment for the simple ASCII H4 titles allowed by this contract. */
|
||||
function headingFragment(title: string): string {
|
||||
return title.toLowerCase().replaceAll('`', '').replaceAll(/[^a-z0-9 _-]/g, '').trim().replaceAll(/\s+/g, '-')
|
||||
}
|
||||
|
||||
/** A direct stable system-prompt contribution, as named by the README contract. */
|
||||
function isDirectSystemPromptSurface(title: string): boolean {
|
||||
return /\bsystem prompt\b/i.test(title)
|
||||
}
|
||||
|
||||
/** Anchored generated-catalog links in one model-view field. */
|
||||
function toolCatalogLinkFragments(text: string): string[] {
|
||||
return [...text.matchAll(/\]\(\.\.\/\.\.\/\.\.\/docs\/tool-catalog\.md#([a-z0-9_-]+)\)/g)]
|
||||
.map(match => match[1] as string)
|
||||
}
|
||||
|
||||
const toolCatalogFragments = new Set<string>()
|
||||
for (const line of readFileSync(resolve(root, 'docs/tool-catalog.md'), 'utf8').split('\n')) {
|
||||
const title = /^## (.+)$/.exec(line)?.[1]
|
||||
if (title !== undefined) toolCatalogFragments.add(headingFragment(title))
|
||||
}
|
||||
|
||||
const failures: Failure[] = []
|
||||
const packageJsons = globSync('packages/*/*/package.json', { cwd: root }).map(path => path.split(sep).join('/')).sort()
|
||||
const scannedPackages = new Set(packageJsons.map(path => path.slice(0, -'/package.json'.length)))
|
||||
let structuredCount = 0
|
||||
let contextSurfaceCount = 0
|
||||
let omittedSectionCount = 0
|
||||
let explainedNoneCount = 0
|
||||
let indirectCount = 0
|
||||
let verbatimBlockCount = 0
|
||||
let systemPromptSurfaceCount = 0
|
||||
let toolSchemaSurfaceCount = 0
|
||||
|
||||
for (const [pkg, reason] of Object.entries(NO_MODEL_EXPERIENCE_SECTION)) {
|
||||
if (!scannedPackages.has(pkg)) {
|
||||
failures.push({ path: `${pkg}/README.md`, message: 'no-section allowlist entry does not name a scanned package' })
|
||||
}
|
||||
if (reason.trim().length === 0) {
|
||||
failures.push({ path: `${pkg}/README.md`, message: 'no-section allowlist entry must retain its audit justification' })
|
||||
}
|
||||
if (SENTENCE_MODEL_EXPERIENCE[pkg] !== undefined) {
|
||||
failures.push({ path: `${pkg}/README.md`, message: 'package cannot appear in both Model Experience allowlists' })
|
||||
}
|
||||
}
|
||||
|
||||
for (const [pkg, contract] of Object.entries(SENTENCE_MODEL_EXPERIENCE)) {
|
||||
if (!scannedPackages.has(pkg)) {
|
||||
failures.push({ path: `${pkg}/README.md`, message: 'sentence allowlist entry does not name a scanned package' })
|
||||
}
|
||||
if (contract.reason.trim().length === 0) {
|
||||
failures.push({ path: `${pkg}/README.md`, message: 'sentence allowlist entry must justify why structured context surfaces are unnecessary' })
|
||||
}
|
||||
}
|
||||
|
||||
for (const packageJson of packageJsons) {
|
||||
const pkg = packageJson.slice(0, -'/package.json'.length)
|
||||
const readme = packageJson.replace(/package\.json$/, 'README.md')
|
||||
const abs = resolve(root, readme)
|
||||
if (!existsSync(abs)) {
|
||||
failures.push({ path: readme, message: 'missing package README' })
|
||||
continue
|
||||
}
|
||||
|
||||
const text = readFileSync(abs, 'utf8')
|
||||
const rawLines = text.split('\n')
|
||||
const lines = markdownProseLines(text)
|
||||
const headings = markdownHeadingLines(text)
|
||||
const h2Headings = headings.filter(heading => heading.depth === 2)
|
||||
const modelExperienceHeadings = headings.filter(heading => heading.text
|
||||
.trim().replaceAll(/\s+/g, ' ').toLowerCase() === 'model experience')
|
||||
const modelHeadings = modelExperienceHeadings.filter(heading => heading.depth === 2 && heading.raw === HEADING)
|
||||
if (NO_MODEL_EXPERIENCE_SECTION[pkg] !== undefined) {
|
||||
if (modelExperienceHeadings.length !== 0) {
|
||||
for (const heading of modelExperienceHeadings) {
|
||||
failures.push({ path: readme, message: `line ${heading.index}: audited model-agnostic package must omit every Model Experience heading; found ${JSON.stringify(heading.raw)}` })
|
||||
}
|
||||
} else {
|
||||
omittedSectionCount += 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
const nonCanonicalModelHeading = modelExperienceHeadings.find(heading => heading.depth !== 2 || heading.raw !== HEADING)
|
||||
if (nonCanonicalModelHeading !== undefined) {
|
||||
failures.push({ path: readme, message: `line ${nonCanonicalModelHeading.index}: non-canonical Model Experience heading ${JSON.stringify(nonCanonicalModelHeading.raw)}; use exactly ${JSON.stringify(HEADING)}` })
|
||||
continue
|
||||
}
|
||||
const modelHeading = modelHeadings.at(0)
|
||||
if (modelHeading === undefined) {
|
||||
failures.push({
|
||||
path: readme,
|
||||
message: `missing ${HEADING}`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (modelHeadings.length !== 1) {
|
||||
failures.push({ path: readme, message: `contains ${modelHeadings.length} copies of ${HEADING}` })
|
||||
continue
|
||||
}
|
||||
const modelH2Index = h2Headings.indexOf(modelHeading)
|
||||
const limitationsH2Index = h2Headings.findIndex(heading => heading.depth === 2 && heading.raw === LIMITATIONS_HEADING)
|
||||
if (limitationsH2Index >= 0) {
|
||||
if (modelH2Index !== h2Headings.length - 2 || limitationsH2Index !== h2Headings.length - 1) {
|
||||
failures.push({
|
||||
path: readme,
|
||||
message: `${HEADING} and ${LIMITATIONS_HEADING} must be the final two H2 sections, in that order`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
} else if (modelH2Index !== h2Headings.length - 1) {
|
||||
failures.push({ path: readme, message: `${HEADING} must be the final H2 when ${LIMITATIONS_HEADING} is absent` })
|
||||
continue
|
||||
}
|
||||
|
||||
const modelHeadingAt = lines.findIndex(line => line.index === modelHeading.index)
|
||||
const body = lines.slice(modelHeadingAt + 1)
|
||||
const h2Lines = new Set(h2Headings.map(heading => heading.index))
|
||||
const nextH2 = body.findIndex(line => h2Lines.has(line.index))
|
||||
const section = nextH2 < 0 ? body : body.slice(0, nextH2)
|
||||
const nextH2Line = nextH2 < 0 ? rawLines.length + 1 : (body[nextH2] as Line).index
|
||||
const rawSection = rawLines.slice(modelHeading.index, nextH2Line - 1)
|
||||
const content = section.filter(line => line.raw.trim().length > 0)
|
||||
const sentenceContract = SENTENCE_MODEL_EXPERIENCE[pkg]
|
||||
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 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` })
|
||||
continue
|
||||
}
|
||||
if (sentenceContract.kind === 'none') explainedNoneCount += 1
|
||||
else indirectCount += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const shortSentence = content.find(line => line.raw === 'None.' || /^None, as |^Indirectly, through /.test(line.raw))
|
||||
if (shortSentence !== undefined) {
|
||||
failures.push({ path: readme, message: `line ${shortSentence.index}: short Model Experience form requires an audited entry in SENTENCE_MODEL_EXPERIENCE` })
|
||||
continue
|
||||
}
|
||||
|
||||
const surfaceStarts = content
|
||||
.map((line, index) => ({ line, index }))
|
||||
.filter(entry => /^### \S/.test(entry.line.raw))
|
||||
if (surfaceStarts.length === 0 || surfaceStarts[0]?.index !== 0) {
|
||||
failures.push({ path: readme, message: 'must contain one or more complete context-surface blocks' })
|
||||
continue
|
||||
}
|
||||
|
||||
const surfaces: ContextSurface[] = []
|
||||
const surfaceFragments = new Set<string>()
|
||||
let surfaceError = false
|
||||
for (let surfaceIndex = 0; surfaceIndex < surfaceStarts.length; surfaceIndex += 1) {
|
||||
const start = surfaceStarts[surfaceIndex] as { line: Line; index: number }
|
||||
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) {
|
||||
failures.push({ path: readme, message: `line ${heading.index}: each context surface requires a non-empty H3 heading` })
|
||||
surfaceError = true
|
||||
break
|
||||
}
|
||||
if (surfaceFragments.has(fragment)) {
|
||||
failures.push({ path: readme, message: `line ${heading.index}: duplicate context-surface link fragment ${JSON.stringify(fragment)}` })
|
||||
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` })
|
||||
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` })
|
||||
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 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` })
|
||||
surfaceError = true
|
||||
break
|
||||
}
|
||||
surfaceFragments.add(fragment)
|
||||
surfaces.push({ heading, modelView, tokenEffect, title, verbatimBlocks: verbatim.blocks })
|
||||
}
|
||||
if (surfaceError) continue
|
||||
|
||||
const promptWithoutVerbatim = surfaces.find(surface => isDirectSystemPromptSurface(surface.title)
|
||||
&& surface.verbatimBlocks === 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` })
|
||||
continue
|
||||
}
|
||||
const hasConcreteLiteral = surfaces.some(surface => surface.verbatimBlocks > 0
|
||||
|| surface.modelView.raw.includes('`')
|
||||
|| surface.tokenEffect.raw.includes('`')
|
||||
|| toolCatalogLinkFragments(surface.modelView.raw).length > 0)
|
||||
if (!hasConcreteLiteral) {
|
||||
failures.push({ path: readme, message: 'structured Model Experience must ground at least one surface with inline code, a nested `markdown` block, or an anchored tool-catalog link' })
|
||||
continue
|
||||
}
|
||||
let catalogError = false
|
||||
for (const surface of surfaces) {
|
||||
if (!/\bschemas?\b/i.test(surface.title)) continue
|
||||
const fragments = toolCatalogLinkFragments(surface.modelView.raw)
|
||||
if (fragments.length === 0) {
|
||||
failures.push({ path: readme, message: `line ${surface.heading.index}: tool-schema surface must link an anchored section of ../../../docs/tool-catalog.md` })
|
||||
catalogError = true
|
||||
break
|
||||
}
|
||||
const invalid = fragments.find(fragment => !toolCatalogFragments.has(fragment))
|
||||
if (invalid !== undefined) {
|
||||
failures.push({ path: readme, message: `line ${surface.modelView.index}: tool-catalog link fragment ${JSON.stringify(invalid)} does not name an H2 section` })
|
||||
catalogError = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (catalogError) continue
|
||||
verbatimBlockCount += surfaces.reduce((total, surface) => total + surface.verbatimBlocks, 0)
|
||||
contextSurfaceCount += surfaces.length
|
||||
systemPromptSurfaceCount += surfaces.filter(surface => isDirectSystemPromptSurface(surface.title)).length
|
||||
toolSchemaSurfaceCount += surfaces.filter(surface => /\bschemas?\b/i.test(surface.title)).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.`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.error('verify-package-readme-model-experience failed:')
|
||||
for (const failure of failures) {
|
||||
console.error(` ${relative(root, resolve(root, failure.path))}: ${failure.message}`)
|
||||
}
|
||||
process.exit(1)
|
||||
@@ -1,31 +1,8 @@
|
||||
/**
|
||||
* Doc-sync gate: enforce the RFC classification scheme
|
||||
* ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md))
|
||||
* and the freshness of the generated index
|
||||
* ([the index-generation RFC](../docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md)).
|
||||
* Every RFC is filed at `docs/rfc/{lifecycle}/{class}/yyyy-mm-dd-topic.md`; the
|
||||
* folder IS the label. This gate is the machine source of truth for the closed
|
||||
* class set and keeps the generated index honest.
|
||||
*
|
||||
* Three checks (all against [rfc-index.ts](./rfc-index.ts), the shared walker
|
||||
* and renderer):
|
||||
*
|
||||
* 1. STRUCTURE — every `.md` under a lifecycle folder lives in a class folder
|
||||
* from CLASSES, is named `yyyy-mm-dd-*.md`, and opens with a parseable H1.
|
||||
* A loose `.md` directly under a lifecycle root (other than the
|
||||
* README/AGENTS allowlist) fails; an unknown class folder fails; a stray
|
||||
* file at an unexpected depth fails. This is what makes the set CLOSED: a
|
||||
* new class folder can't appear without amending CLASSES (and the README's
|
||||
* Classification section, per the RFC).
|
||||
* 2. FRESHNESS — the committed `docs/rfc/INDEX.md` byte-matches a fresh render
|
||||
* from the tree, so every RFC is listed exactly once, under the heading
|
||||
* matching its path, with its H1 title and filename date. The fix for a
|
||||
* stale index is `pnpm run gen-rfc-index`, never a hand edit. This is
|
||||
* checker, not fixer: it reports and never rewrites.
|
||||
* 3. NO STRAY ROWS — `docs/rfc/README.md` (the curated front door) carries no
|
||||
* index-shaped table rows; the list lives only in the generated INDEX.md.
|
||||
*
|
||||
* Run: `tsx scripts/verify-rfc-classification.ts`.
|
||||
* 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'
|
||||
|
||||
@@ -1,31 +1,8 @@
|
||||
/**
|
||||
* Doc-sync gate: enforce the RFC in-file format
|
||||
* ([README.md § The file format](../docs/rfc/README.md), the contract; rationale in
|
||||
* [the uniform-format RFC](../docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md)).
|
||||
* The classification gate owns WHERE a file sits and how it is named; this gate
|
||||
* owns what is INSIDE: the header block, the per-lifecycle body skeleton, and
|
||||
* the Alternatives-considered mandate.
|
||||
*
|
||||
* Per English RFC (`.zh.md` counterparts are the pairing gate's concern):
|
||||
*
|
||||
* 1. HEADER — line 1 is `# RFC: <title>`, line 2 blank, line 3 the one
|
||||
* `Status:` line in the file, line 4 blank. The status is the dateless enum
|
||||
* matching the lifecycle folder: `Status: proposed`, `Status: implemented`,
|
||||
* or `Status: rejected — <reason>`.
|
||||
* 2. SKELETON — the first `##` section is `## Problem`; the lifecycle's
|
||||
* required sections are present under their canonical names (`proposed/`:
|
||||
* Proposal, Acceptance criteria, Risks; `implemented/`: Decision,
|
||||
* Consequences; `rejected/`: Proposal); `implemented/` must not carry the
|
||||
* proposal-era headings (Proposal, Plan, Migration plan, Acceptance
|
||||
* criteria) that the docs standard's slop checklist outlaws there.
|
||||
* 3. ALTERNATIVES — `## Alternatives considered` is present, or the file is a
|
||||
* pre-format RFC (dated before the format landed) carrying the exact
|
||||
* grandfather comment instead. Carrying both, or grandfathering a
|
||||
* post-format RFC, fails.
|
||||
* 4. DEBT MARKER — the retired legacy-format debt comment may not reappear.
|
||||
*
|
||||
* Checker, not fixer: it reports and never rewrites.
|
||||
* Run: `tsx scripts/verify-rfc-format.ts`.
|
||||
* Enforce RFC 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`.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
@@ -65,9 +42,7 @@ for (const rfc of rfcs) {
|
||||
errors.push(`format: ${rfc.rel} — ${msg}`)
|
||||
}
|
||||
const lines = readFileSync(resolve(rfcRoot, rfc.rel), 'utf8').split('\n')
|
||||
// Content scans ignore fenced code blocks: an RFC may legitimately QUOTE a
|
||||
// status line, a banned heading, or the grandfather comment inside a fence
|
||||
// (the README's own format section does), and only real prose counts.
|
||||
// Format tokens inside fenced examples are not document structure.
|
||||
let inFence = false
|
||||
const prose = lines.filter((l) => {
|
||||
if (l.startsWith('```')) {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Verify that the executable deploy manifest supplies every required workspace
|
||||
* peer in its dependency graph. With auto peer installation disabled, a missing
|
||||
* root peer can otherwise fail only when Cordis loads the packaged plugin.
|
||||
*/
|
||||
import { readFile, readdir } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
|
||||
interface PackageManifest {
|
||||
name?: string
|
||||
dependencies?: Record<string, string>
|
||||
optionalDependencies?: Record<string, string>
|
||||
peerDependencies?: Record<string, string>
|
||||
peerDependenciesMeta?: Record<string, { optional?: boolean }>
|
||||
}
|
||||
|
||||
interface WorkspacePackage {
|
||||
path: string
|
||||
manifest: PackageManifest
|
||||
}
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const { values } = parseArgs({
|
||||
args: process.argv.slice(2),
|
||||
options: { manifest: { type: 'string' } },
|
||||
})
|
||||
const runtimeManifestPath = resolve(root, values.manifest ?? 'python/sdk-runtime/package.json')
|
||||
const runtimeManifest = await loadManifest(runtimeManifestPath)
|
||||
const runtimeName = runtimeManifest.name ?? 'python/sdk-runtime'
|
||||
const workspace = await loadWorkspacePackages()
|
||||
const runtimeDependencies = runtimeManifest.dependencies ?? {}
|
||||
const parents = new Map<string, string | undefined>()
|
||||
const queue: string[] = []
|
||||
|
||||
for (const dependency of Object.keys(runtimeDependencies).sort()) {
|
||||
if (!workspace.has(dependency)) continue
|
||||
parents.set(dependency, undefined)
|
||||
queue.push(dependency)
|
||||
}
|
||||
|
||||
const failures: string[] = []
|
||||
for (let index = 0; index < queue.length; index += 1) {
|
||||
const packageName = queue[index]
|
||||
if (packageName === undefined) continue
|
||||
const current = workspace.get(packageName)
|
||||
if (current === undefined) continue
|
||||
const peers = current.manifest.peerDependencies ?? {}
|
||||
const peerMeta = current.manifest.peerDependenciesMeta ?? {}
|
||||
for (const peer of Object.keys(peers).sort()) {
|
||||
if (!workspace.has(peer) || peerMeta[peer]?.optional === true) continue
|
||||
if (runtimeDependencies[peer]?.startsWith('workspace:') === true) continue
|
||||
failures.push(`${formatChain(runtimeName, packageName, parents)} -> ${peer}`)
|
||||
}
|
||||
const dependencies = {
|
||||
...current.manifest.dependencies,
|
||||
...current.manifest.optionalDependencies,
|
||||
}
|
||||
for (const dependency of Object.keys(dependencies).sort()) {
|
||||
if (!workspace.has(dependency) || parents.has(dependency)) continue
|
||||
parents.set(dependency, packageName)
|
||||
queue.push(dependency)
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('verify-runtime-closure: required workspace peers are missing from python/sdk-runtime dependencies:')
|
||||
for (const failure of failures) console.error(` ${failure}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`verify-runtime-closure: ${queue.length} workspace packages form a closed runtime dependency graph.`)
|
||||
|
||||
async function loadWorkspacePackages(): Promise<Map<string, WorkspacePackage>> {
|
||||
const paths: string[] = []
|
||||
for (const group of await childDirectories(join(root, 'packages'))) {
|
||||
for (const packageDir of await childDirectories(join(root, 'packages', group))) {
|
||||
paths.push(join(root, 'packages', group, packageDir, 'package.json'))
|
||||
}
|
||||
}
|
||||
for (const packageDir of await childDirectories(join(root, 'vendor'))) {
|
||||
paths.push(join(root, 'vendor', packageDir, 'package.json'))
|
||||
}
|
||||
const result = new Map<string, WorkspacePackage>()
|
||||
for (const path of paths) {
|
||||
const manifest = await loadManifest(path)
|
||||
if (manifest.name !== undefined) result.set(manifest.name, { path, manifest })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function childDirectories(path: string): Promise<string[]> {
|
||||
const entries = await readdir(path, { withFileTypes: true })
|
||||
return entries.filter(entry => entry.isDirectory()).map(entry => entry.name).sort()
|
||||
}
|
||||
|
||||
async function loadManifest(path: string): Promise<PackageManifest> {
|
||||
return JSON.parse(await readFile(path, 'utf8')) as PackageManifest
|
||||
}
|
||||
|
||||
function formatChain(
|
||||
runtimeName: string,
|
||||
packageName: string,
|
||||
parents: ReadonlyMap<string, string | undefined>,
|
||||
): string {
|
||||
const chain = [packageName]
|
||||
let parent = parents.get(packageName)
|
||||
while (parent !== undefined) {
|
||||
chain.unshift(parent)
|
||||
parent = parents.get(parent)
|
||||
}
|
||||
return [runtimeName, ...chain].join(' -> ')
|
||||
}
|
||||
@@ -1,69 +1,33 @@
|
||||
/**
|
||||
* Doc-sync gate: enforce the bilingual pairing contract (docs/i18n/README.md).
|
||||
* English and Chinese carry EQUAL authority — either language may be authored
|
||||
* first — so consistency is recorded per pair in a sidecar metadata file,
|
||||
* `foo.i18n.yaml`, holding the full git blob hash of BOTH files as of the last
|
||||
* time a human confirmed the two say the same thing:
|
||||
*
|
||||
* foo.md: <40-hex blob hash>
|
||||
* foo.zh.md: <40-hex blob hash>
|
||||
*
|
||||
* The gate checks, mechanically, the checkable half of the contract:
|
||||
*
|
||||
* 1. Every file in the manifest's `required` list has a COMPLETE pair
|
||||
* (the enforcement frontier — grows batch by batch).
|
||||
* 2. Every pair that exists at all is complete and consistent: all three
|
||||
* files present (a `.zh.md` or a `.i18n.yaml` without its counterparts
|
||||
* is an error — pairs merge whole, never half), each side's current
|
||||
* blob hash equals the recorded one (an edit to EITHER side without a
|
||||
* re-confirmed counterpart goes red), both sides carry the language
|
||||
* switcher, and the structural signatures match one to one — heading
|
||||
* depths in order, fenced code blocks VERBATIM (info string + content),
|
||||
* table column counts, list kinds, and every link target except the
|
||||
* switcher itself.
|
||||
* 3. `excluded` files (generated docs, agent instructions, the bilingual
|
||||
* terminology table) have no `.zh.md` and no `.i18n.yaml` at all.
|
||||
*
|
||||
* What it deliberately does NOT check is translation quality or which side
|
||||
* is "right": a green gate means the pair was confirmed consistent at these
|
||||
* exact contents, not that the confirmation was sound — accuracy,
|
||||
* terminology, and tone are the human reviewer's half of the contract
|
||||
* (docs/i18n/translation-rules.md).
|
||||
*
|
||||
* Blob hashes, not commit hashes, so a pair edited in the same PR verifies
|
||||
* without any history lookup: consistency is a pure content comparison,
|
||||
* computed here directly (sha1 of `blob <size>\0<content>`) without spawning
|
||||
* git. The recorded hash also recovers the last-confirmed text of either
|
||||
* side (`git cat-file -p <hash>`) for diff-based minimal updates.
|
||||
*
|
||||
* Run: `tsx scripts/verify-translation-pairing.ts` — or with `--list` to
|
||||
* print the pairing state of every in-scope document as a work list (always
|
||||
* exits 0), or with `--write` to (re)record both hashes for every complete
|
||||
* pair after you have brought the two sides back in line (the resulting
|
||||
* yaml diff is the reviewable act of confirming consistency).
|
||||
* Enforce complete English/Chinese pairs, matching structure, and recorded git
|
||||
* blob hashes under the bilingual manifest. Required files and date-named docs
|
||||
* at or after `requiredSince` must be paired; excluded docs may have neither a
|
||||
* counterpart nor sidecar. `--list` reports state and `--write` records both
|
||||
* sides after human review. Translation quality remains a review responsibility.
|
||||
* See `docs/i18n/README.md` for the owning contract.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { basename, join, resolve } 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 { basename, join, resolve, sep } from 'node:path'
|
||||
import {
|
||||
datedDocumentDate,
|
||||
linksTo,
|
||||
parseTranslationMarkdown,
|
||||
parseTranslationPairingManifest,
|
||||
requiresPairByDate,
|
||||
translationStructureDiff,
|
||||
translationStructureSignature,
|
||||
} from './translation-pairing.ts'
|
||||
|
||||
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 and the docs tree. */
|
||||
const SCOPE_PATTERNS = ['README.md', 'README.zh.md', 'README.i18n.yaml', 'docs/**/*.md', 'docs/**/*.i18n.yaml']
|
||||
/** 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']
|
||||
|
||||
/** The enforcement frontier and the never-paired set (docs/i18n/README.md § Scope). */
|
||||
interface Manifest {
|
||||
required: string[]
|
||||
excluded: string[]
|
||||
}
|
||||
const manifest = JSON.parse(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8')) as Manifest
|
||||
const manifest = parseTranslationPairingManifest(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8'))
|
||||
|
||||
/**
|
||||
* An excluded entry ending in `/` excludes the whole directory. The trailing
|
||||
@@ -115,102 +79,10 @@ function renderMeta(source: string, sourceHash: string, zh: string, zhHash: stri
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* The structural signature the two sides must share, as ordered sequences so
|
||||
* a swap or a level change is caught, not just a count change. Prose is
|
||||
* deliberately absent: the gate checks shape, never wording.
|
||||
*/
|
||||
interface Signature {
|
||||
/** Heading depths in document order (h2 → 2). */
|
||||
headings: number[]
|
||||
/** Fenced code blocks verbatim: info string + content, in order. */
|
||||
code: string[]
|
||||
/** Column count of each table, in order. */
|
||||
tables: number[]
|
||||
/** Each list's kind (ordered vs bullet), in order. */
|
||||
lists: string[]
|
||||
/** Every link target in order, the language switcher's excluded. */
|
||||
links: string[]
|
||||
}
|
||||
|
||||
/** Whether the tree contains a link to exactly `target` (the switcher check). */
|
||||
function linksTo(tree: Nodes, target: string): boolean {
|
||||
let found = false
|
||||
const visit = (node: Nodes): void => {
|
||||
if (node.type === 'link' && node.url === target) found = true
|
||||
if ('children' in node) for (const child of node.children) visit(child)
|
||||
}
|
||||
visit(tree)
|
||||
return found
|
||||
}
|
||||
|
||||
/** Collect the structural signature, skipping links to `switcherTarget`. */
|
||||
function signatureOf(tree: Nodes, switcherTarget: string): Signature {
|
||||
const sig: Signature = { headings: [], code: [], tables: [], lists: [], links: [] }
|
||||
const visit = (node: Nodes): void => {
|
||||
switch (node.type) {
|
||||
case 'heading':
|
||||
sig.headings.push(node.depth)
|
||||
break
|
||||
case 'code':
|
||||
sig.code.push(`\`\`\`${node.lang ?? ''}${node.meta ? ` ${node.meta}` : ''}\n${node.value}`)
|
||||
break
|
||||
case 'table':
|
||||
sig.tables.push(node.children[0]?.children.length ?? 0)
|
||||
break
|
||||
case 'list':
|
||||
sig.lists.push(node.ordered ? 'ordered' : 'bullet')
|
||||
break
|
||||
case 'link':
|
||||
if (node.url !== switcherTarget) sig.links.push(node.url)
|
||||
break
|
||||
default:
|
||||
// Every other node kind is prose or container — not part of the signature.
|
||||
break
|
||||
}
|
||||
if ('children' in node) for (const child of node.children) visit(child)
|
||||
}
|
||||
visit(tree)
|
||||
return sig
|
||||
}
|
||||
|
||||
/** Render a signature element for an error message, truncated for readability. */
|
||||
function show(value: string | number | undefined): string {
|
||||
if (value === undefined) return 'nothing'
|
||||
const text = JSON.stringify(value)
|
||||
return text.length > 72 ? `${text.slice(0, 72)}…` : text
|
||||
}
|
||||
|
||||
/** First divergence between two signatures, as messages; empty when identical. */
|
||||
function signatureDiff(source: Signature, zh: Signature): string[] {
|
||||
const out: string[] = []
|
||||
const fields: [string, (string | number)[], (string | number)[]][] = [
|
||||
['heading (depth)', source.headings, zh.headings],
|
||||
['code block', source.code, zh.code],
|
||||
['table (column count)', source.tables, zh.tables],
|
||||
['list (kind)', source.lists, zh.lists],
|
||||
['link target', source.links, zh.links],
|
||||
]
|
||||
for (const [field, s, z] of fields) {
|
||||
const length = Math.max(s.length, z.length)
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (s[i] !== z[i]) {
|
||||
out.push(`${field} #${i + 1} diverges between the pair: ${show(s[i])} vs ${show(z[i])}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function parse(content: string): Nodes {
|
||||
return fromMarkdown(content, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
|
||||
}
|
||||
|
||||
// Enumerate the scope once.
|
||||
const files = new Set<string>()
|
||||
for (const pattern of SCOPE_PATTERNS) {
|
||||
for (const match of globSync(pattern, { cwd: root })) files.add(match)
|
||||
for (const match of globSync(pattern, { cwd: root })) files.add(match.split(sep).join('/'))
|
||||
}
|
||||
const translations = [...files].filter(f => f.endsWith('.zh.md')).sort()
|
||||
const metas = [...files].filter(f => f.endsWith('.i18n.yaml')).sort()
|
||||
@@ -249,7 +121,21 @@ for (const req of manifest.required) {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Every pair that exists at all is complete and consistent. Anchor on the
|
||||
// 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
|
||||
// the filename alone — no git history, so it holds on shallow CI checkouts.
|
||||
for (const source of sources) {
|
||||
if (isExcluded(source)) continue
|
||||
const date = datedDocumentDate(source)
|
||||
if (!requiresPairByDate(source, manifest.requiredSince) || date === undefined) continue
|
||||
const { zh } = pairPaths(source)
|
||||
if (!existsSync(join(root, zh))) {
|
||||
errors.push(`${source}: dated ${date} — documents dated on/after ${manifest.requiredSince} merge bilingual (docs/i18n/README.md); add the counterpart and record the pair`)
|
||||
state.set(source, 'missing')
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Every pair that exists at all is complete and consistent. Anchor on the
|
||||
// union of .zh.md files and .i18n.yaml records so a half-deleted pair is
|
||||
// caught from either remnant.
|
||||
const pairAnchors = new Set<string>()
|
||||
@@ -292,15 +178,18 @@ for (const source of [...pairAnchors].sort()) {
|
||||
continue
|
||||
}
|
||||
|
||||
const sourceTree = parse(sourceContent.toString('utf8'))
|
||||
const zhTree = parse(zhContent.toString('utf8'))
|
||||
const sourceTree = parseTranslationMarkdown(sourceContent.toString('utf8'))
|
||||
const zhTree = parseTranslationMarkdown(zhContent.toString('utf8'))
|
||||
if (!linksTo(zhTree, basename(source))) {
|
||||
errors.push(`${zh}: missing language switcher — no link to ${basename(source)}`)
|
||||
}
|
||||
if (!linksTo(sourceTree, basename(zh))) {
|
||||
errors.push(`${source}: missing language switcher — no link back to ${basename(zh)}`)
|
||||
}
|
||||
for (const divergence of signatureDiff(signatureOf(sourceTree, basename(zh)), signatureOf(zhTree, basename(source)))) {
|
||||
for (const divergence of translationStructureDiff(
|
||||
translationStructureSignature(sourceTree, basename(zh)),
|
||||
translationStructureSignature(zhTree, basename(source)),
|
||||
)) {
|
||||
errors.push(`${source} ↔ ${zh}: ${divergence}`)
|
||||
}
|
||||
if (!state.has(source)) state.set(source, 'ok')
|
||||
@@ -316,7 +205,8 @@ if (listMode) {
|
||||
const rows = [...state.entries()].sort((a, b) => order[a[1]] - order[b[1]] || a[0].localeCompare(b[0]))
|
||||
for (const [file, status] of rows) {
|
||||
const required = manifest.required.includes(file)
|
||||
console.log(`${status.padEnd(11)} ${file}${status === 'missing' ? (required ? ' (required)' : ' (backlog)') : ''}`)
|
||||
const tag = required ? ' (required)' : requiresPairByDate(file, manifest.requiredSince) ? ' (required by date)' : ' (backlog)'
|
||||
console.log(`${status.padEnd(11)} ${file}${status === 'missing' ? tag : ''}`)
|
||||
}
|
||||
const counts = { 'ok': 0, 'out-of-sync': 0, 'missing': 0 }
|
||||
for (const status of state.values()) counts[status]++
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/** Verify that the committed translation prompt renders and parses as documented. */
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import {
|
||||
documentedTranslationPromptPlaceholders,
|
||||
parseTranslationResponse,
|
||||
renderTranslationPrompt,
|
||||
renderTranslationResponse,
|
||||
TRANSLATION_PROMPT_PLACEHOLDERS,
|
||||
} from './translation-prompt.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
function read(path: string): string {
|
||||
return readFileSync(join(root, path), 'utf8')
|
||||
}
|
||||
|
||||
try {
|
||||
const document = read('docs/i18n/translation-prompt.md')
|
||||
const translationRules = read('docs/i18n/translation-rules.md')
|
||||
const terminology = read('docs/i18n/terminology.md')
|
||||
const documented = documentedTranslationPromptPlaceholders(document)
|
||||
if (documented.join('\n') !== TRANSLATION_PROMPT_PLACEHOLDERS.join('\n')) {
|
||||
throw new Error(`placeholder table must list exactly: ${TRANSLATION_PROMPT_PLACEHOLDERS.join(', ')}`)
|
||||
}
|
||||
|
||||
const englishSource = renderTranslationPrompt(document, {
|
||||
sourceLanguage: 'English',
|
||||
sourceFilename: 'example.md',
|
||||
translationRules,
|
||||
terminology,
|
||||
})
|
||||
const chineseSource = renderTranslationPrompt(document, {
|
||||
sourceLanguage: 'Chinese',
|
||||
sourceFilename: 'example.zh.md',
|
||||
translationRules,
|
||||
terminology,
|
||||
})
|
||||
if (!englishSource.includes('[English](example.md) | 中文')) throw new Error('English-source render does not carry the Chinese switcher instruction')
|
||||
if (!chineseSource.includes('English | [中文](example.zh.md)')) throw new Error('Chinese-source render does not carry the English switcher instruction')
|
||||
|
||||
const example = /```xml\n([\s\S]*?)\n```/.exec(englishSource)?.[1]
|
||||
if (example === undefined) throw new Error('rendered prompt has no XML response example')
|
||||
parseTranslationResponse(example)
|
||||
|
||||
const roundTrip = { translation: 'first ]]> pass', review: '- [None] No corrections.', final: 'final ]]> text' }
|
||||
const parsed = parseTranslationResponse(renderTranslationResponse(roundTrip))
|
||||
if (JSON.stringify(parsed) !== JSON.stringify(roundTrip)) throw new Error('CDATA split rule does not round-trip response content')
|
||||
|
||||
console.log('verify-translation-prompt: both directions render and the XML response contract parses.')
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`verify-translation-prompt: ${message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -1,40 +1,16 @@
|
||||
/**
|
||||
* Doc-sync gate: verify every ` ```ts type-equiv ` block in the docs is a
|
||||
* VERBATIM copy of the source type definition it documents.
|
||||
*
|
||||
* The core-data-structures docs paste real type definitions so a reader sees
|
||||
* the exact shape. A paste drifts the moment source changes — this script is
|
||||
* the drift guard. For each block it extracts the documented symbol's
|
||||
* declaration from source via the TypeScript compiler API, whitespace-
|
||||
* normalizes both the source text and the block, and asserts they are equal.
|
||||
*
|
||||
* Provenance lives in a central manifest (`scripts/type-equiv.manifest.json`),
|
||||
* NOT in the doc prose: each entry names `{ doc, symbol, source }`. The script
|
||||
* enforces a 1:1 correspondence — every type-equiv block in the docs has
|
||||
* exactly one manifest entry (keyed by doc + declared symbol), and every
|
||||
* manifest entry resolves to exactly one block. An orphan on either side fails,
|
||||
* so a block can never be silently unchecked and an entry can never rot.
|
||||
*
|
||||
* doc-typecheck.ts recognizes the same ` ```ts type-equiv ` fence and skips it
|
||||
* (it is not standalone-compilable and is not counted in the opt-out ratio);
|
||||
* the two scripts share the fence, this one owns the verification.
|
||||
*
|
||||
* Run: `tsx scripts/verify-type-equiv.ts`.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
/**
|
||||
* Markdown globs scanned for ` ```ts type-equiv ` blocks — the SAME scope
|
||||
* doc-typecheck uses. Scanning every doc (not only the docs the manifest names)
|
||||
* is what makes the 1:1 guarantee real in both directions: a type-equiv block
|
||||
* added to a doc with NO manifest entry is still discovered here and reported as
|
||||
* an orphan, instead of being silently skipped.
|
||||
*/
|
||||
/** Scan doc-typecheck's full Markdown scope so unmanifested blocks also fail. */
|
||||
const MARKDOWN_GLOBS = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'website/zh-CN/**/*.md']
|
||||
|
||||
/** One manifest entry: a documented type-equiv block and its source symbol. */
|
||||
@@ -58,12 +34,11 @@ interface EquivBlock {
|
||||
code: string
|
||||
}
|
||||
|
||||
/** Collapse a declaration to its structural form for comparison: drop comments
|
||||
* (block + line), then collapse all whitespace runs to single spaces. This lets
|
||||
* a doc block show a CLEAN definition (without source's verbose inline JSDoc)
|
||||
* while still guaranteeing the field shapes match — drift in a field name or
|
||||
* type fails; a reworded inline comment does not. Adequate for our own type
|
||||
* source (no string literal contains `//` or `/* */`); not a general tokenizer. */
|
||||
/**
|
||||
* 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 {
|
||||
return code
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
@@ -72,8 +47,7 @@ function normalize(code: string): string {
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** Strip a leading `export ` / `export default ` modifier — the doc block shows
|
||||
* the bare declaration, the source carries the export modifier. */
|
||||
/** Strip source-only export modifiers. */
|
||||
function stripExport(code: string): string {
|
||||
return code.replace(/^export\s+(default\s+)?/, '')
|
||||
}
|
||||
@@ -147,7 +121,7 @@ const keyOf = (x: { doc: string; symbol: string }): string => `${x.doc}::${x.sym
|
||||
// as an orphan rather than silently skipped.
|
||||
const docSet = new Set<string>()
|
||||
for (const pattern of MARKDOWN_GLOBS) {
|
||||
for (const match of globSync(pattern, { cwd: root })) docSet.add(match)
|
||||
for (const match of globSync(pattern, { cwd: root })) docSet.add(match.split(sep).join('/'))
|
||||
}
|
||||
const blocks: EquivBlock[] = [...docSet].sort().flatMap(extractEquivBlocks)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user